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
+41
View File
@@ -0,0 +1,41 @@
{
"schemaVersion": 1,
"name": "msa-agent-policy",
"comment": "Authorisation surface for relay-issued commands. Deliberately narrower than the web console proxy policy: this is what the iPhone app can actually ask for, nothing more.",
"egress": {
"mode": "loopback-only",
"portOnly": true,
"comment": "The agent config carries a port, never a host. Outbound connections are dialed to 127.0.0.1/::1 by the transport itself, so there is no host field to misconfigure and no DNS-rebinding path.",
"dialedHosts": ["127.0.0.1", "::1"]
},
"authPaths": {
"pattern": "^/api/(?:auth(?:/|$)|login(?:/|$)|logout(?:/|$))",
"proxied": false,
"comment": "The agent logs into the local SimAdmin itself and holds the credential. Login material never traverses the relay in either direction."
},
"read": [
"/api/health",
"/api/device",
"/api/sim",
"/api/network",
"/api/network/signal-strength",
"/api/stats",
"/api/stats/cpu",
"/api/connectivity",
"/api/sms/list"
],
"write": {
"/api/sms/send": ["POST"],
"/api/sms/batch-delete": ["POST"]
},
"dangerous": ["/api/sms/send", "/api/sms/batch-delete"],
"limits": {
"dangerousWritesPerMinute": 6,
"maxRequestBytes": 262144,
"maxResponseBytes": 262144,
"requestTtlSeconds": 30,
"clockSkewSeconds": 120,
"replayCacheSize": 4096,
"mailboxDepth": 8
}
}
+232
View File
@@ -0,0 +1,232 @@
// Package policy loads and enforces the agent authorisation surface.
//
// The policy is embedded rather than read from disk: a security boundary that lives in an editable
// file can be silently widened on one host, and "that box was configured differently" is the kind
// of finding that only surfaces after an incident. The build vendors contracts/agent-policy.json
// into this directory, and TestVendoredCopyMatchesContracts fails if the vendored copy drifts.
package policy
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"regexp"
"strings"
_ "embed"
)
// The directive must sit immediately above the variable; a blank line here silently detaches it.
// Vendoring is a make step so the copy under this directory is never hand-edited.
//
//go:embed agent-policy.json
var embedded []byte
type Egress struct {
Mode string `json:"mode"`
PortOnly bool `json:"portOnly"`
DialedHosts []string `json:"dialedHosts"`
}
type AuthPaths struct {
Pattern string `json:"pattern"`
Proxied bool `json:"proxied"`
}
type Limits struct {
DangerousWritesPerMinute int `json:"dangerousWritesPerMinute"`
MaxRequestBytes int `json:"maxRequestBytes"`
MaxResponseBytes int `json:"maxResponseBytes"`
RequestTTLSeconds int `json:"requestTtlSeconds"`
ClockSkewSeconds int `json:"clockSkewSeconds"`
ReplayCacheSize int `json:"replayCacheSize"`
MailboxDepth int `json:"mailboxDepth"`
}
type Policy struct {
SchemaVersion int `json:"schemaVersion"`
Name string `json:"name"`
Egress Egress `json:"egress"`
AuthPaths AuthPaths `json:"authPaths"`
Read []string `json:"read"`
Write map[string][]string `json:"write"`
Dangerous []string `json:"dangerous"`
Limits Limits `json:"limits"`
authRe *regexp.Regexp
read map[string]bool
write map[string]map[string]bool
dangerous map[string]bool
digest string
}
// Load returns the embedded policy. It never falls back to a permissive default: if the embedded
// bytes fail validation the agent must not start.
func Load() (*Policy, error) { return Parse(embedded) }
func Parse(raw []byte) (*Policy, error) {
var p Policy
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("policy: decode: %w", err)
}
if p.SchemaVersion != 1 {
return nil, fmt.Errorf("policy: unsupported schemaVersion %d", p.SchemaVersion)
}
if p.Egress.Mode != "loopback-only" || !p.Egress.PortOnly {
return nil, errors.New("policy: egress must be loopback-only and port-only")
}
if len(p.Egress.DialedHosts) == 0 {
return nil, errors.New("policy: egress.dialedHosts is empty")
}
for _, host := range p.Egress.DialedHosts {
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return nil, fmt.Errorf("policy: egress.dialedHosts entry %q is not a literal loopback address", host)
}
}
if p.AuthPaths.Proxied {
return nil, errors.New("policy: authPaths must not be proxied")
}
re, err := regexp.Compile(p.AuthPaths.Pattern)
if err != nil {
return nil, fmt.Errorf("policy: authPaths.pattern: %w", err)
}
p.authRe = re
p.read = make(map[string]bool, len(p.Read))
for _, path := range p.Read {
if err := p.checkPath(path); err != nil {
return nil, err
}
if re.MatchString(path) {
return nil, fmt.Errorf("policy: read path %q matches the auth pattern", path)
}
if p.read[path] {
return nil, fmt.Errorf("policy: duplicate read path %q", path)
}
p.read[path] = true
}
p.write = make(map[string]map[string]bool, len(p.Write))
p.dangerous = make(map[string]bool, len(p.Dangerous))
for _, path := range p.Dangerous {
p.dangerous[path] = true
}
for path, methods := range p.Write {
if err := p.checkPath(path); err != nil {
return nil, err
}
if re.MatchString(path) {
return nil, fmt.Errorf("policy: write path %q matches the auth pattern", path)
}
if !p.dangerous[path] {
return nil, fmt.Errorf("policy: write path %q is not declared dangerous, so it would run without a confirmation intent", path)
}
set := make(map[string]bool, len(methods))
for _, method := range methods {
if method == "GET" || method == "HEAD" {
return nil, fmt.Errorf("policy: write path %q must not allow %s", path, method)
}
if set[method] {
return nil, fmt.Errorf("policy: write path %q duplicates method %s", path, method)
}
set[method] = true
}
if len(set) == 0 {
return nil, fmt.Errorf("policy: write path %q has no methods", path)
}
p.write[path] = set
}
for path := range p.dangerous {
if _, ok := p.write[path]; !ok {
return nil, fmt.Errorf("policy: %q is declared dangerous but is not writable", path)
}
}
for name, value := range map[string]int{
"dangerousWritesPerMinute": p.Limits.DangerousWritesPerMinute,
"maxRequestBytes": p.Limits.MaxRequestBytes,
"maxResponseBytes": p.Limits.MaxResponseBytes,
"requestTtlSeconds": p.Limits.RequestTTLSeconds,
"clockSkewSeconds": p.Limits.ClockSkewSeconds,
"replayCacheSize": p.Limits.ReplayCacheSize,
"mailboxDepth": p.Limits.MailboxDepth,
} {
if value <= 0 {
return nil, fmt.Errorf("policy: limits.%s must be positive, got %d", name, value)
}
}
if p.Limits.ClockSkewSeconds > 300 {
return nil, errors.New("policy: clockSkewSeconds is too wide to bound replay")
}
if p.Limits.RequestTTLSeconds > p.Limits.ClockSkewSeconds {
return nil, errors.New("policy: requestTtlSeconds exceeds clockSkewSeconds and would never be enforced")
}
if p.Limits.ReplayCacheSize < p.Limits.MailboxDepth {
return nil, errors.New("policy: replayCacheSize is smaller than mailboxDepth, so entries can be evicted while still queued")
}
sum := sha256.Sum256(raw)
p.digest = hex.EncodeToString(sum[:])
return &p, nil
}
func (p *Policy) checkPath(path string) error {
if !strings.HasPrefix(path, "/api/") {
return fmt.Errorf("policy: path %q is not under /api/", path)
}
if strings.Contains(path, "..") || strings.ContainsAny(path, "?#\\") {
return fmt.Errorf("policy: path %q contains traversal, query, fragment or backslash", path)
}
return nil
}
// SHA256 is the digest of the exact bytes the agent enforced. It is logged at startup so an operator
// can confirm a fleet is running the intended policy without diffing files.
func (p *Policy) SHA256() string { return p.digest }
// Decision reports whether a request may be executed, and whether it needs a confirmation intent.
func (p *Policy) Decision(method, path string) (allowed bool, dangerous bool, reason string) {
clean := path
if i := strings.IndexAny(clean, "?#"); i >= 0 {
clean = clean[:i]
}
if err := p.checkPath(clean); err != nil {
return false, false, "path rejected"
}
// Constant-time on purpose is not needed for a public allowlist, but the auth check must come
// first so a path that is both auth-shaped and allowlisted cannot win by ordering.
if p.authRe.MatchString(clean) {
return false, false, "authentication endpoints are not reachable through the relay"
}
if method == "GET" {
if p.read[clean] {
return true, false, ""
}
return false, false, "path is not on the read allowlist"
}
methods, ok := p.write[clean]
if !ok {
if p.read[clean] {
return false, false, "path is read-only"
}
return false, false, "path is not on the write allowlist"
}
if !methods[method] {
return false, false, "method is not allowed for this path"
}
return true, true, ""
}
// RequiresConfirm reports whether a dangerous write carries a matching intent. The comparison is
// constant time so the agent does not leak the expected digest byte by byte on a wrong guess.
func RequiresConfirm(got, want []byte) bool {
return subtle.ConstantTimeCompare(got, want) == 1
}
// Raw returns the embedded bytes for the drift test.
func Raw() []byte { return embedded }
+210
View File
@@ -0,0 +1,210 @@
package policy
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
)
// TestVendoredCopyMatchesContracts fails loudly when the embedded policy and contracts/ drift apart.
// Without this the shipped binary can enforce something other than what the tests in test/ checked,
// which is exactly the silent-divergence case embedding was supposed to avoid.
func TestVendoredCopyMatchesContracts(t *testing.T) {
upstream, err := os.ReadFile(filepath.Join("..", "..", "..", "contracts", "agent-policy.json"))
if err != nil {
t.Fatalf("read contracts/agent-policy.json: %v", err)
}
if !bytes.Equal(upstream, Raw()) {
t.Fatal("internal/policy/agent-policy.json differs from contracts/agent-policy.json; run make vendor")
}
}
func loaded(t *testing.T) *Policy {
t.Helper()
p, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
return p
}
func TestDecisionTable(t *testing.T) {
p := loaded(t)
cases := []struct {
method, path string
allowed bool
dangerous bool
}{
{"GET", "/api/health", true, false},
{"GET", "/api/sms/list", true, false},
{"GET", "/api/health?fresh=1", true, false},
{"POST", "/api/sms/send", true, true},
{"POST", "/api/sms/batch-delete", true, true},
// Reads must not be writable even though the path is known.
{"POST", "/api/health", false, false},
{"DELETE", "/api/sms/list", false, false},
// Console-only surface that the app never constructs.
{"GET", "/api/esim/profiles", false, false},
{"POST", "/api/device-network/ddns/config", false, false},
{"DELETE", "/api/sim/1", false, false},
// Auth is the agent's own business.
{"POST", "/api/auth/login", false, false},
{"GET", "/api/auth/logout", false, false},
{"POST", "/api/login", false, false},
// Traversal and off-api targets.
{"GET", "/etc/passwd", false, false},
{"GET", "/api/../etc/passwd", false, false},
{"GET", "/api/health/../../etc/passwd", false, false},
{"GET", "", false, false},
{"", "/api/health", false, false},
}
for _, c := range cases {
allowed, dangerous, reason := p.Decision(c.method, c.path)
if allowed != c.allowed {
t.Errorf("%s %s: allowed=%v want %v (reason %q)", c.method, c.path, allowed, c.allowed, reason)
}
if allowed && dangerous != c.dangerous {
t.Errorf("%s %s: dangerous=%v want %v", c.method, c.path, dangerous, c.dangerous)
}
if !allowed && reason == "" {
t.Errorf("%s %s: denied without a reason", c.method, c.path)
}
}
}
// TestFailClosedOnTampering walks the mutations an operator or a bad merge could plausibly produce
// and asserts Parse rejects each one rather than degrading to something looser.
func TestFailClosedOnTampering(t *testing.T) {
base := Raw()
mutate := func(t *testing.T, set func(m map[string]any)) []byte {
t.Helper()
var m map[string]any
if err := json.Unmarshal(base, &m); err != nil {
t.Fatalf("unmarshal base: %v", err)
}
set(m)
out, err := json.Marshal(m)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return out
}
t.Run("non loopback egress host", func(t *testing.T) {
raw := mutate(t, func(m map[string]any) {
m["egress"].(map[string]any)["dialedHosts"] = []any{"10.0.0.9"}
})
if _, err := Parse(raw); err == nil {
t.Fatal("accepted a routable egress host")
}
})
t.Run("hostname egress", func(t *testing.T) {
raw := mutate(t, func(m map[string]any) {
m["egress"].(map[string]any)["dialedHosts"] = []any{"attacker.example"}
})
if _, err := Parse(raw); err == nil {
t.Fatal("accepted a hostname; that reopens DNS rebinding and /etc/hosts pivots")
}
})
t.Run("mode changed", func(t *testing.T) {
raw := mutate(t, func(m map[string]any) {
m["egress"].(map[string]any)["mode"] = "any"
})
if _, err := Parse(raw); err == nil {
t.Fatal("accepted egress.mode=any")
}
})
t.Run("auth proxied", func(t *testing.T) {
raw := mutate(t, func(m map[string]any) {
m["authPaths"].(map[string]any)["proxied"] = true
})
if _, err := Parse(raw); err == nil {
t.Fatal("accepted proxied auth paths")
}
})
t.Run("read path is an auth path", func(t *testing.T) {
raw := mutate(t, func(m map[string]any) {
m["read"] = append(m["read"].([]any), "/api/auth/session")
})
if _, err := Parse(raw); err == nil {
t.Fatal("accepted an auth path on the read allowlist")
}
})
t.Run("write without danger", func(t *testing.T) {
raw := mutate(t, func(m map[string]any) {
m["write"].(map[string]any)["/api/sim/disable"] = []any{"POST"}
})
if _, err := Parse(raw); err == nil {
t.Fatal("accepted a write that is not declared dangerous")
}
})
t.Run("write allows GET", func(t *testing.T) {
raw := mutate(t, func(m map[string]any) {
m["write"].(map[string]any)["/api/sms/send"] = []any{"GET"}
})
if _, err := Parse(raw); err == nil {
t.Fatal("accepted GET as a write method")
}
})
t.Run("dangerous path not writable", func(t *testing.T) {
raw := mutate(t, func(m map[string]any) {
m["dangerous"] = append(m["dangerous"].([]any), "/api/stats")
})
if _, err := Parse(raw); err == nil {
t.Fatal("accepted a dangerous path with no write entry")
}
})
t.Run("ttl beyond skew", func(t *testing.T) {
raw := mutate(t, func(m map[string]any) {
m["limits"].(map[string]any)["requestTtlSeconds"] = 600
})
if _, err := Parse(raw); err == nil {
t.Fatal("accepted a TTL the replay window cannot cover")
}
})
t.Run("replay cache below mailbox", func(t *testing.T) {
raw := mutate(t, func(m map[string]any) {
m["limits"].(map[string]any)["replayCacheSize"] = 2
})
if _, err := Parse(raw); err == nil {
t.Fatal("accepted a replay cache that evicts live requests")
}
})
t.Run("schema bump", func(t *testing.T) {
raw := mutate(t, func(m map[string]any) {
m["schemaVersion"] = 2
})
if _, err := Parse(raw); err == nil {
t.Fatal("accepted an unknown schemaVersion")
}
})
t.Run("traversal path", func(t *testing.T) {
raw := mutate(t, func(m map[string]any) {
m["read"] = append(m["read"].([]any), "/api/../etc/shadow")
})
if _, err := Parse(raw); err == nil {
t.Fatal("accepted a traversal path")
}
})
t.Run("malformed json", func(t *testing.T) {
if _, err := Parse([]byte("{")); err == nil {
t.Fatal("accepted malformed json")
}
})
}
func TestDigestIsStable(t *testing.T) {
p := loaded(t)
if len(p.SHA256()) != 64 {
t.Fatalf("digest looks wrong: %q", p.SHA256())
}
again, err := Load()
if err != nil {
t.Fatalf("Load twice: %v", err)
}
if again.SHA256() != p.SHA256() {
t.Fatal("digest changed between loads")
}
}