baseline: P1 契约、agent 核心与中继投递(既有工作首次入库)

This commit is contained in:
chick
2026-09-16 08:41:13 +08:00
commit 54d9696a4d
24 changed files with 4858 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
package runtime
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// Entry is one line of the audit log.
//
// Metadata only, and that is the whole design: the request and response bodies are the actual
// credentials and SMS contents, and copying them into a second file on the same box adds a place to
// lose them without adding any investigative value. What is kept is enough to answer "who asked for
// what, when, and was it allowed" from the relay request id.
type Entry struct {
At time.Time `json:"ts"`
Node string `json:"node,omitempty"`
Req string `json:"req,omitempty"`
App string `json:"app,omitempty"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Status int `json:"status"`
Outcome string `json:"outcome"`
}
// Audit is an append-only JSONL writer. One line per request, flushed as it is written: an audit log
// that loses its tail on SIGKILL cannot explain the incident that killed the process.
type Audit struct {
mu sync.Mutex
sink *os.File
enc *json.Encoder
}
// OpenAudit appends to path, creating it 0600 and its parent directories if needed.
//
// The file is opened append-only and never rotated in-process. Rotation is logrotate's job: an
// agent that renames its own log is an agent that can lose an entry during the rename.
func OpenAudit(path string) (*Audit, error) {
if path == "" {
return nil, errors.New("runtime: audit path is empty")
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
sink, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return nil, err
}
return &Audit{sink: sink, enc: json.NewEncoder(sink)}, nil
}
// NullAudit returns a writer that keeps nothing. Used by tests and by `msa-agent --audit=-`, because
// the executor is simpler when it can assume a non-nil audit target.
func NullAudit() *Audit { return &Audit{} }
// Write appends one entry. A write failure is returned to the caller rather than panicking: the
// audit log is evidence, not a control, and a full disk must not become a remote denial of service.
func (a *Audit) Write(entry Entry) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.sink == nil {
return nil
}
if entry.At.IsZero() {
entry.At = time.Now().UTC()
}
entry.At = entry.At.UTC()
if err := a.enc.Encode(entry); err != nil {
return fmt.Errorf("runtime: audit write: %w", err)
}
return a.sink.Sync()
}
// Close releases the file. Subsequent writes are silently dropped, which is the right behaviour for
// a shutdown path: failing the request that happens to race with a clean exit buys nothing.
func (a *Audit) Close() error {
a.mu.Lock()
defer a.mu.Unlock()
if a.sink == nil {
return nil
}
err := a.sink.Close()
a.sink = nil
return err
}
+272
View File
@@ -0,0 +1,272 @@
package runtime
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"fun.chick/msa-agent/internal/egress"
"fun.chick/msa-agent/internal/policy"
"fun.chick/msa-agent/internal/wire"
)
// Refusal is classified, and the classification is part of the contract with the operator. A node
// with a drifted clock and a node under attack produce the same outward symptom; collapsing both
// into "denied" guarantees the operator will respond to the wrong one.
type Refusal struct {
Class string
Path string
Err error
}
func (r Refusal) Error() string { return fmt.Sprintf("%s: %v", r.Class, r.Err) }
// Refusal classes, mirrored by the relay's ProblemDetails codes so one string is searchable across
// the agent log, the relay log and the phone.
const (
ClassBadEnvelope = "bad_envelope"
ClassSignature = "bad_signature"
ClassExpired = "expired"
ClassReplay = "replay"
ClassPolicy = "policy_denied"
ClassNoConfirm = "confirmation_missing"
ClassBadConfirm = "confirmation_mismatch"
ClassRateLimited = "rate_limited"
ClassTooLarge = "payload_too_large"
ClassUpstream = "upstream_error"
ClassLoopback = "egress_violation"
)
// Executor turns a decrypted InnerRequest into one loopback HTTP call.
//
// It holds the only HTTP client in the process, built by egress.NewClient, so there is no path from
// a request body to a socket that is not the loopback one.
type Executor struct {
client *http.Client
target egress.Target
pol *policy.Policy
replay *ReplayCache
audit *Audit
clock func() time.Time
limiter *rateLimiter
}
// Config is what the agent needs to build an Executor. Port is a number and nothing else: no host,
// by the same reasoning as egress.Target.
type Config struct {
Port int
Timeout time.Duration
Audit *Audit
Clock func() time.Time
DangerousPerMin int
}
// NewExecutor wires the policy, the loopback client and the replay cache together.
//
// It fails closed: an unusable policy or an unbuildable client is a startup error, because an agent
// that starts with a permissive fallback would be indistinguishable from one that is working.
func NewExecutor(pol *policy.Policy, cfg Config) (*Executor, error) {
if pol == nil {
return nil, errors.New("runtime: policy is nil")
}
target, err := egress.NewTarget(cfg.Port)
if err != nil {
return nil, err
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 20 * time.Second
}
client, err := egress.NewClient(target, timeout)
if err != nil {
return nil, err
}
// The cache ttl is the skew window, not an independent knob. An id older than the widest
// acceptable timestamp can never be accepted again, so keeping it costs memory and buys nothing.
replay, err := NewReplayCache(pol.Limits.ReplayCacheSize,
time.Duration(pol.Limits.ClockSkewSeconds)*time.Second)
if err != nil {
return nil, err
}
clock := cfg.Clock
if clock == nil {
clock = time.Now
}
perMinute := cfg.DangerousPerMin
if perMinute <= 0 {
perMinute = pol.Limits.DangerousWritesPerMinute
}
return &Executor{
client: client,
target: target,
pol: pol,
replay: replay,
audit: cfg.Audit,
clock: clock,
limiter: newRateLimiter(perMinute, time.Minute),
}, nil
}
// Result is what comes back from the local SimAdmin, or why it did not.
type Result struct {
Status int
Body []byte
}
// Execute validates, then performs, exactly one request.
//
// Order matters and is not cosmetic. The replay and freshness gates run before the policy decision
// so a denied request still burns its request id: otherwise an attacker could probe the allowlist
// by resubmitting one signed request with a dozen paths. The intent is checked after the policy
// decision because the expected digest depends on the path that survived it.
func (e *Executor) Execute(ctx context.Context, envelope wire.Envelope, inner wire.InnerRequest) (Result, *Refusal) {
now := e.clock()
if err := Freshness(envelope.TS, uint64(now.Unix()), e.pol.Limits.ClockSkewSeconds); err != nil {
return Result{}, e.refuse(envelope, inner, ClassExpired, err)
}
if e.replay.Seen(envelope.Req, now) {
return Result{}, e.refuse(envelope, inner, ClassReplay, fmt.Errorf("%w: %s", ErrReplay, envelope.Req))
}
method := strings.ToUpper(strings.TrimSpace(inner.Method))
allowed, dangerous, reason := e.pol.Decision(method, inner.Path)
if !allowed {
return Result{}, e.refuse(envelope, inner, ClassPolicy, errors.New(reason))
}
if len(inner.Body) > e.pol.Limits.MaxRequestBytes {
return Result{}, e.refuse(envelope, inner, ClassTooLarge,
fmt.Errorf("body is %d bytes, limit is %d", len(inner.Body), e.pol.Limits.MaxRequestBytes))
}
if dangerous {
// Ordering inside the dangerous path: rate limit first, then intent. A caller who can send
// unlimited attempts can also brute-force a 32-byte digest in principle, and the limit is
// the cheaper of the two checks to apply.
if !e.limiter.Allow(now) {
return Result{}, e.refuse(envelope, inner, ClassRateLimited, errors.New("too many dangerous writes"))
}
want, err := wire.IntentDigest(method, inner.Path, inner.Body)
if err != nil {
return Result{}, e.refuse(envelope, inner, ClassBadConfirm, err)
}
if len(inner.Intent) == 0 {
return Result{}, e.refuse(envelope, inner, ClassNoConfirm, errors.New("dangerous write carries no confirmation intent"))
}
if !policy.RequiresConfirm(inner.Intent, want) {
return Result{}, e.refuse(envelope, inner, ClassBadConfirm, errors.New("confirmation intent does not match the request being sent"))
}
}
result, err := e.forward(ctx, method, inner.Path, inner.Body)
if err != nil {
class := ClassUpstream
if errors.Is(err, ErrResponseTooLarge) {
class = ClassTooLarge
}
return Result{}, e.refuse(envelope, inner, class, err)
}
e.record(envelope, method, inner.Path, result.Status, "ok")
return result, nil
}
// forward performs the loopback call. The origin comes from egress.Target, which has no host field,
// and the path has already survived policy.Decision, so nothing here is assembled from parts that
// a caller could steer off the machine.
func (e *Executor) forward(ctx context.Context, method, path string, body []byte) (Result, error) {
if !strings.HasPrefix(path, "/api/") {
return Result{}, ClassLoopbackErr
}
var reader io.Reader
if len(body) > 0 {
reader = strings.NewReader(string(body))
}
request, err := http.NewRequestWithContext(ctx, method, e.target.Origin()+path, reader)
if err != nil {
return Result{}, fmt.Errorf("%w: %v", ClassLoopbackErr, err)
}
if len(body) > 0 {
request.Header.Set("Content-Type", "application/json")
}
// No credential is attached here. The agent logs into the local SimAdmin itself on startup and
// holds the session; a phone cannot use this path to present a node password it does not have.
response, err := e.client.Do(request)
if err != nil {
return Result{}, fmt.Errorf("local SimAdmin unreachable: %w", err)
}
defer response.Body.Close()
limited := io.LimitReader(response.Body, int64(e.pol.Limits.MaxResponseBytes)+1)
payload, err := io.ReadAll(limited)
if err != nil {
return Result{}, fmt.Errorf("reading local response: %w", err)
}
if len(payload) > e.pol.Limits.MaxResponseBytes {
return Result{}, fmt.Errorf("%w: %s answered with more than %d bytes",
ErrResponseTooLarge, path, e.pol.Limits.MaxResponseBytes)
}
return Result{Status: response.StatusCode, Body: payload}, nil
}
// ClassLoopbackErr is the named error used when a request could not even be constructed.
var ClassLoopbackErr = errors.New("request could not be built for the loopback target")
// ErrResponseTooLarge is returned when the local SimAdmin answers with more than the policy allows.
//
// The response is dropped rather than truncated. A truncated body is not a smaller answer, it is a
// broken one: JSON cut off at an arbitrary byte offset parses as nothing, and the phone would show
// a decoding failure that points at the app instead of at the node that answered with 3 MB.
var ErrResponseTooLarge = errors.New("local response exceeds the policy limit")
func (e *Executor) refuse(envelope wire.Envelope, inner wire.InnerRequest, class string, err error) *Refusal {
e.record(envelope, strings.ToUpper(strings.TrimSpace(inner.Method)), inner.Path, 0, class)
return &Refusal{Class: class, Path: inner.Path, Err: err}
}
func (e *Executor) record(envelope wire.Envelope, method, path string, status int, outcome string) {
if e.audit == nil {
return
}
e.audit.Write(Entry{
At: e.clock(),
Node: envelope.Node,
Req: envelope.Req,
App: envelope.App,
Method: method,
Path: path,
Status: status,
Outcome: outcome,
})
}
// rateLimiter is a fixed-window counter. Deliberately not a token bucket: the property wanted is
// "at most N dangerous writes in any 60 seconds", and a bucket's refill rate makes that harder to
// state and to test.
type rateLimiter struct {
mu sync.Mutex
limit int
window time.Duration
start time.Time
consumed int
}
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
return &rateLimiter{limit: limit, window: window}
}
func (r *rateLimiter) Allow(now time.Time) bool {
r.mu.Lock()
defer r.mu.Unlock()
if r.start.IsZero() || now.Sub(r.start) >= r.window {
r.start = now
r.consumed = 0
}
if r.consumed >= r.limit {
return false
}
r.consumed++
return true
}
+127
View File
@@ -0,0 +1,127 @@
// Package runtime is where a verified envelope becomes a local HTTP call, or is refused.
//
// The checks here are ordered, and the order is the security property: a request is only decrypted
// after its signature verifies, only routed after its policy decision, and only executed after its
// confirmation intent is shown to match the exact bytes about to be forwarded. Every refusal is
// classified, because the failure modes are indistinguishable from the outside — a node whose clock
// has drifted looks exactly like an attacker replaying a request — and an operator who cannot tell
// them apart will "fix" it by widening the policy.
package runtime
import (
"errors"
"sync"
"time"
)
// ReplayClass names why a request was refused, in terms an operator can act on.
type ReplayClass string
const (
// ReplayDuplicate means this request id was already executed.
ReplayDuplicate ReplayClass = "replay"
// ReplayExpired means the timestamp is outside the clock-skew window.
ReplayExpired ReplayClass = "expired"
)
var (
// ErrReplay is returned for a request id that has already been executed.
ErrReplay = errors.New("runtime: request id was already used")
// ErrExpired is returned for a timestamp outside the clock-skew window.
ErrExpired = errors.New("runtime: request timestamp is outside the allowed window")
)
// ReplayCache is a bounded set of request ids that have already run.
//
// Bounded, not growing: an attacker who can send traffic can also send distinct request ids, and a
// cache that grows to fit them is a memory exhaustion primitive. The bound is set from
// limits.replayCacheSize, which the policy asserts is larger than the relay mailbox depth, so an
// entry can never be evicted while a copy of that request is still queued.
//
// Eviction order is insertion order rather than LRU. LRU would let a flood of fresh ids push out
// the one id an attacker wants to reuse; FIFO eviction still admits that eventually, but only after
// the whole window has turned over, and the timestamp window expires it first in any case.
type ReplayCache struct {
mu sync.Mutex
entries map[string]time.Time
order []string
limit int
ttl time.Duration
}
// NewReplayCache builds a cache holding at most limit ids, each forgotten after ttl even if the
// cache is not yet full. The ttl is what makes the bound independent of traffic volume: ids older
// than the skew window can no longer be accepted anyway.
func NewReplayCache(limit int, ttl time.Duration) (*ReplayCache, error) {
if limit <= 0 {
return nil, errors.New("runtime: replay cache limit must be positive")
}
if ttl <= 0 {
return nil, errors.New("runtime: replay cache ttl must be positive")
}
return &ReplayCache{
entries: make(map[string]time.Time, limit),
order: make([]string, 0, limit),
limit: limit,
ttl: ttl,
}, nil
}
// Seen reports whether id has already been executed, and records it if not.
//
// The check and the insert are one call, not two. Splitting them invites a caller to check, decide,
// and then forget to record — which is a replay window that no test would name.
func (c *ReplayCache) Seen(id string, now time.Time) bool {
c.mu.Lock()
defer c.mu.Unlock()
c.expire(now)
if _, ok := c.entries[id]; ok {
return true
}
if len(c.order) >= c.limit {
evicted := c.order[0]
c.order = c.order[1:]
delete(c.entries, evicted)
}
c.order = append(c.order, id)
c.entries[id] = now
return false
}
// Len reports how many ids are held. Used by the health surface and by tests.
func (c *ReplayCache) Len(now time.Time) int {
c.mu.Lock()
defer c.mu.Unlock()
c.expire(now)
return len(c.entries)
}
// expire drops entries older than the ttl. Called under the caller's lock, never exported.
func (c *ReplayCache) expire(now time.Time) {
cutoff := now.Add(-c.ttl)
for len(c.order) > 0 {
oldest := c.order[0]
stamp, ok := c.entries[oldest]
if !ok || stamp.After(cutoff) {
return
}
c.order = c.order[1:]
delete(c.entries, oldest)
}
}
// Freshness applies the timestamp window. It is separate from the cache so the two refusals stay
// distinguishable in the audit log: "expired" means set the clock, "replay" means investigate.
func Freshness(requestTS, now uint64, skewSeconds int) error {
if skewSeconds <= 0 {
return errors.New("runtime: clock skew window must be positive")
}
delta := int64(now) - int64(requestTS)
if delta < 0 {
delta = -delta
}
if delta > int64(skewSeconds) {
return ErrExpired
}
return nil
}
+471
View File
@@ -0,0 +1,471 @@
package runtime
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"fun.chick/msa-agent/internal/policy"
"fun.chick/msa-agent/internal/wire"
)
// The tests below drive the executor against a real local HTTP server, because the property under
// test is "the request that reaches SimAdmin is the one the policy allowed". A fake transport would
// only prove the executor calls an interface.
func testPolicy(t *testing.T) *policy.Policy {
t.Helper()
pol, err := policy.Load()
if err != nil {
t.Fatalf("policy.Load: %v", err)
}
return pol
}
// localServer starts a loopback server and returns its port. The executor is pointed at the port
// only; the server's own host is never handed to it.
func localServer(t *testing.T, handler http.Handler) int {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
parsed, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
port, err := strconv.Atoi(parsed.Port())
if err != nil {
t.Fatal(err)
}
return port
}
type fixture struct {
executor *Executor
now time.Time
mu sync.Mutex
}
func (f *fixture) clock() time.Time {
f.mu.Lock()
defer f.mu.Unlock()
return f.now
}
func (f *fixture) advance(d time.Duration) {
f.mu.Lock()
defer f.mu.Unlock()
f.now = f.now.Add(d)
}
func newFixture(t *testing.T, handler http.Handler) *fixture {
t.Helper()
f := &fixture{now: time.Unix(1_800_000_000, 0).UTC()}
executor, err := NewExecutor(testPolicy(t), Config{
Port: localServer(t, handler),
Timeout: 5 * time.Second,
Audit: NullAudit(),
Clock: f.clock,
})
if err != nil {
t.Fatalf("NewExecutor: %v", err)
}
f.executor = executor
return f
}
func envelopeAt(node, req string, ts uint64) wire.Envelope {
return wire.Envelope{
V: wire.Version, Node: node, Req: req, App: "ap_test0000", TS: ts,
Alg: wire.Suite, EK: make([]byte, 32), P: []byte{1},
}
}
func request(method, path string, body []byte) wire.InnerRequest {
return wire.InnerRequest{Method: method, Path: path, Body: body}
}
func TestReadOnTheAllowlistIsForwarded(t *testing.T) {
var seen http.Request
f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = *r
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
}))
result, refusal := f.executor.Execute(context.Background(),
envelopeAt("nd_test0001", "11111111-1111-4111-8111-111111111111", uint64(f.now.Unix())),
request("GET", "/api/health", nil))
if refusal != nil {
t.Fatalf("read was refused: %v", refusal)
}
if result.Status != 200 || string(result.Body) != `{"ok":true}` {
t.Fatalf("got %d %q", result.Status, result.Body)
}
if seen.Method != "GET" || seen.URL.Path != "/api/health" {
t.Fatalf("upstream saw %s %s", seen.Method, seen.URL.Path)
}
if seen.Host != "127.0.0.1" && !strings.HasPrefix(seen.Host, "127.0.0.1:") {
t.Fatalf("upstream saw host %q, want the loopback literal", seen.Host)
}
}
func TestTheSameRequestIDIsRejectedTheSecondTime(t *testing.T) {
f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
envelope := envelopeAt("nd_test0001", "22222222-2222-4222-8222-222222222222", uint64(f.now.Unix()))
if _, refusal := f.executor.Execute(context.Background(), envelope, request("GET", "/api/health", nil)); refusal != nil {
t.Fatalf("first call refused: %v", refusal)
}
_, refusal := f.executor.Execute(context.Background(), envelope, request("GET", "/api/health", nil))
if refusal == nil || refusal.Class != ClassReplay {
t.Fatalf("second call: got %v, want %s", refusal, ClassReplay)
}
}
// A denied request must still burn its id. If it did not, an attacker holding one signed request
// could walk it down the whole allowlist by changing only the path.
func TestADeniedRequestStillBurnsItsID(t *testing.T) {
f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("upstream must not be called for a path outside the policy")
}))
envelope := envelopeAt("nd_test0001", "33333333-3333-4333-8333-333333333333", uint64(f.now.Unix()))
if _, refusal := f.executor.Execute(context.Background(), envelope, request("GET", "/api/secrets", nil)); refusal == nil || refusal.Class != ClassPolicy {
t.Fatalf("got %v, want %s", refusal, ClassPolicy)
}
_, refusal := f.executor.Execute(context.Background(), envelope, request("GET", "/api/health", nil))
if refusal == nil || refusal.Class != ClassReplay {
t.Fatalf("reuse after a denial: got %v, want %s", refusal, ClassReplay)
}
}
func TestAClockedRequestIsClassifiedAsExpiredNotReplay(t *testing.T) {
f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
pol := testPolicy(t)
stale := uint64(f.now.Unix()) - uint64(pol.Limits.ClockSkewSeconds) - 1
_, refusal := f.executor.Execute(context.Background(),
envelopeAt("nd_test0001", "44444444-4444-4444-8444-444444444444", stale),
request("GET", "/api/health", nil))
if refusal == nil || refusal.Class != ClassExpired {
t.Fatalf("got %v, want %s", refusal, ClassExpired)
}
future := uint64(f.now.Unix()) + uint64(pol.Limits.ClockSkewSeconds) + 1
_, refusal = f.executor.Execute(context.Background(),
envelopeAt("nd_test0001", "55555555-5555-4555-8555-555555555555", future),
request("GET", "/api/health", nil))
if refusal == nil || refusal.Class != ClassExpired {
t.Fatalf("clock ahead: got %v, want %s", refusal, ClassExpired)
}
}
func TestAuthPathsAreUnreachable(t *testing.T) {
f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("the login endpoint must never be dialed on behalf of a phone")
}))
for index, path := range []string{"/api/auth/login", "/api/login", "/api/logout"} {
_, refusal := f.executor.Execute(context.Background(),
envelopeAt("nd_test0001", newReqID(index), uint64(f.now.Unix())),
request("POST", path, []byte(`{}`)))
if refusal == nil || refusal.Class != ClassPolicy {
t.Fatalf("%s: got %v, want %s", path, refusal, ClassPolicy)
}
}
}
// The whole point of the intent: a relay that holds a confirmation for one write cannot spend it on
// another, even when the two have identical bodies.
func TestADangerousWriteNeedsAMatchingIntent(t *testing.T) {
calls := 0
f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(200)
w.Write([]byte(`{"sent":true}`))
}))
body := []byte(`{"phone_number":"+8613800000000","content":"hello"}`)
if _, refusal := f.executor.Execute(context.Background(),
envelopeAt("nd_test0001", "77777777-7777-4777-8777-777777777777", uint64(f.now.Unix())),
request("POST", "/api/sms/send", body)); refusal == nil || refusal.Class != ClassNoConfirm {
t.Fatalf("without an intent: got %v, want %s", refusal, ClassNoConfirm)
}
right, err := wire.IntentDigest("POST", "/api/sms/send", body)
if err != nil {
t.Fatal(err)
}
other, err := wire.IntentDigest("POST", "/api/sms/batch-delete", body)
if err != nil {
t.Fatal(err)
}
if _, refusal := f.executor.Execute(context.Background(),
envelopeAt("nd_test0001", "88888888-8888-4888-8888-888888888888", uint64(f.now.Unix())),
wire.InnerRequest{Method: "POST", Path: "/api/sms/send", Body: body, Intent: other}); refusal == nil || refusal.Class != ClassBadConfirm {
t.Fatalf("borrowed intent: got %v, want %s", refusal, ClassBadConfirm)
}
// Lower-cased method and reordered keys must still match: the intent covers the meaning of the
// request, not the spelling the phone happened to use.
reordered := []byte(`{"content":"hello","phone_number":"+8613800000000"}`)
recomputed, err := wire.IntentDigest("post", "/api/sms/send", reordered)
if err != nil {
t.Fatal(err)
}
if string(recomputed) != string(right) {
t.Fatal("intent digest depends on formatting, which it must not")
}
result, refusal := f.executor.Execute(context.Background(),
envelopeAt("nd_test0001", "99999999-9999-4999-8999-999999999999", uint64(f.now.Unix())),
wire.InnerRequest{Method: "POST", Path: "/api/sms/send", Body: body, Intent: right})
if refusal != nil {
t.Fatalf("with a matching intent: %v", refusal)
}
if result.Status != 200 {
t.Fatalf("status %d", result.Status)
}
if calls != 1 {
t.Fatalf("upstream called %d times, want 1", calls)
}
}
// Intent must bind the body too. A confirmation for "send to 13800000000" is not consent to send
// to 13800000001, and the digest is the only thing that says so.
func TestAnIntentDoesNotCoverAnEditedBody(t *testing.T) {
f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("an edited body must not reach upstream")
}))
intent, err := wire.IntentDigest("POST", "/api/sms/send", []byte(`{"phone_number":"+8613800000000","content":"hi"}`))
if err != nil {
t.Fatal(err)
}
_, refusal := f.executor.Execute(context.Background(),
envelopeAt("nd_test0001", "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", uint64(f.now.Unix())),
wire.InnerRequest{Method: "POST", Path: "/api/sms/send",
Body: []byte(`{"phone_number":"+8613800000001","content":"hi"}`), Intent: intent})
if refusal == nil || refusal.Class != ClassBadConfirm {
t.Fatalf("got %v, want %s", refusal, ClassBadConfirm)
}
}
func TestDangerousWritesAreRateLimited(t *testing.T) {
f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
body := []byte(`{"phone_number":"+8613800000000","content":"hello"}`)
intent, err := wire.IntentDigest("POST", "/api/sms/send", body)
if err != nil {
t.Fatal(err)
}
limit := testPolicy(t).Limits.DangerousWritesPerMinute
allowed := 0
for i := 0; i < limit+2; i++ {
inner := wire.InnerRequest{Method: "POST", Path: "/api/sms/send", Body: body, Intent: intent}
envelope := envelopeAt("nd_test0001", newReqID(i), uint64(f.now.Unix()))
if _, refusal := f.executor.Execute(context.Background(), envelope, inner); refusal == nil {
allowed++
} else if refusal.Class != ClassRateLimited {
t.Fatalf("attempt %d refused as %s", i, refusal.Class)
}
}
if allowed != limit {
t.Fatalf("allowed %d dangerous writes, want exactly %d", allowed, limit)
}
// The window is fixed, not sliding: moving past it restores the full budget.
f.advance(time.Minute)
envelope := envelopeAt("nd_test0001", "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", uint64(f.now.Unix()))
if _, refusal := f.executor.Execute(context.Background(), envelope,
wire.InnerRequest{Method: "POST", Path: "/api/sms/send", Body: body, Intent: intent}); refusal != nil {
t.Fatalf("after the window: %v", refusal)
}
}
func newReqID(i int) string {
return "cccccccc-cccc-4ccc-8ccc-" + strings.Repeat("0", 11) + strconv.Itoa(i)
}
// An oversized answer is refused, not truncated. A truncated body is a broken body: JSON cut at an
// arbitrary offset parses as nothing, and the user would see a decoding error that blames the app
// for a node that answered with 3 MB.
func TestOversizedResponsesAreRefusedNotTruncated(t *testing.T) {
limit := testPolicy(t).Limits.MaxResponseBytes
f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(strings.Repeat("x", limit+1024)))
}))
_, refusal := f.executor.Execute(context.Background(),
envelopeAt("nd_test0001", "dddddddd-dddd-4ddd-8ddd-dddddddddddd", uint64(f.now.Unix())),
request("GET", "/api/health", nil))
if refusal == nil || refusal.Class != ClassTooLarge {
t.Fatalf("got %v, want %s", refusal, ClassTooLarge)
}
}
// The boundary itself must pass: a response of exactly the limit is a normal response.
func TestAResponseExactlyAtTheLimitIsForwarded(t *testing.T) {
limit := testPolicy(t).Limits.MaxResponseBytes
f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(strings.Repeat("x", limit)))
}))
result, refusal := f.executor.Execute(context.Background(),
envelopeAt("nd_test0001", "d1d1d1d1-d1d1-41d1-81d1-d1d1d1d1d1d1", uint64(f.now.Unix())),
request("GET", "/api/health", nil))
if refusal != nil {
t.Fatalf("refused: %v", refusal)
}
if len(result.Body) != limit {
t.Fatalf("body is %d bytes, want %d", len(result.Body), limit)
}
}
func TestAnUnreachableLocalServiceIsAnUpstreamError(t *testing.T) {
pol := testPolicy(t)
// A port with nothing on it: the loopback dial fails, which is a node problem rather than a
// policy problem, and the refusal class must say so.
executor, err := NewExecutor(pol, Config{Port: 1, Timeout: 500 * time.Millisecond, Audit: NullAudit()})
if err != nil {
t.Fatal(err)
}
_, refusal := executor.Execute(context.Background(),
envelopeAt("nd_test0001", "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", uint64(time.Now().Unix())),
request("GET", "/api/health", nil))
if refusal == nil || refusal.Class != ClassUpstream {
t.Fatalf("got %v, want %s", refusal, ClassUpstream)
}
}
func TestReplayCacheBoundsAndTTL(t *testing.T) {
cache, err := NewReplayCache(3, time.Minute)
if err != nil {
t.Fatal(err)
}
base := time.Unix(1_800_000_000, 0)
for i := 0; i < 3; i++ {
if cache.Seen(strconv.Itoa(i), base) {
t.Fatalf("id %d reported as seen", i)
}
}
if !cache.Seen("0", base) {
t.Fatal("id 0 was evicted before the cache was full")
}
// Past the ttl every entry is gone, so the bound and the window are independent of each other.
later := base.Add(2 * time.Minute)
if cache.Len(later) != 0 {
t.Fatalf("cache holds %d entries past its ttl", cache.Len(later))
}
if cache.Seen("0", later) {
t.Fatal("an expired id is still reported as seen")
}
}
func TestFreshnessWindow(t *testing.T) {
if err := Freshness(1000, 1000, 120); err != nil {
t.Fatalf("same timestamp: %v", err)
}
if err := Freshness(1000, 1121, 120); !errors.Is(err, ErrExpired) {
t.Fatalf("121s old: %v", err)
}
// Symmetric: a request stamped in the future is as suspicious as an old one, and the window is
// a bound on |now - ts|, not on age.
if err := Freshness(1121, 1000, 120); !errors.Is(err, ErrExpired) {
t.Fatalf("121s ahead: %v", err)
}
}
func TestAuditWritesOneJSONLinePerRequest(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "audit.log")
audit, err := OpenAudit(path)
if err != nil {
t.Fatal(err)
}
defer audit.Close()
stamp := time.Unix(1_800_000_000, 0).UTC()
if err := audit.Write(Entry{At: stamp, Node: "nd_1", Req: "r1", Method: "GET", Path: "/api/health", Status: 200, Outcome: "ok"}); err != nil {
t.Fatal(err)
}
if err := audit.Write(Entry{At: stamp, Req: "r2", Outcome: ClassPolicy}); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
if len(lines) != 2 {
t.Fatalf("got %d lines, want 2", len(lines))
}
var first Entry
if err := json.Unmarshal([]byte(lines[0]), &first); err != nil {
t.Fatal(err)
}
if first.Node != "nd_1" || first.Status != 200 || !first.At.Equal(stamp) {
t.Fatalf("first entry round-tripped as %+v", first)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("audit file mode is %v, want 0600", info.Mode().Perm())
}
}
// A second run over the same log must append, not truncate: an agent restart is normal and must not
// erase the explanation for what happened before it.
func TestAuditAppendsAcrossRestarts(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.log")
first, err := OpenAudit(path)
if err != nil {
t.Fatal(err)
}
if err := first.Write(Entry{Req: "r1", Outcome: "ok"}); err != nil {
t.Fatal(err)
}
if err := first.Close(); err != nil {
t.Fatal(err)
}
second, err := OpenAudit(path)
if err != nil {
t.Fatal(err)
}
defer second.Close()
if err := second.Write(Entry{Req: "r2", Outcome: ClassReplay}); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if got := strings.Count(string(raw), "\n"); got != 2 {
t.Fatalf("log has %d lines, want 2", got)
}
}
func TestNullAuditAcceptsWritesWithoutAFile(t *testing.T) {
audit := NullAudit()
if err := audit.Write(Entry{Req: "r"}); err != nil {
t.Fatalf("null audit returned %v", err)
}
if err := audit.Close(); err != nil {
t.Fatalf("null audit close returned %v", err)
}
}
func TestExecutorRefusesToStartWithoutAPolicyOrPort(t *testing.T) {
if _, err := NewExecutor(nil, Config{Port: 8788}); err == nil {
t.Fatal("a nil policy was accepted")
}
if _, err := NewExecutor(testPolicy(t), Config{Port: 0}); err == nil {
t.Fatal("port 0 was accepted")
}
}