Compare commits
11
Commits
3a9eec5de2
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc7a77c500 | ||
|
|
3473816167 | ||
|
|
7d174a0261 | ||
|
|
934760f8e2 | ||
|
|
a4439331e3 | ||
|
|
62b322af39 | ||
|
|
5feb5bfe70 | ||
|
|
b5f548f1ad | ||
|
|
0f983f046a | ||
|
|
274e25961a | ||
|
|
9608a7ebc0 |
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# Cross-compiled binaries. The build is reproducible from the Makefile, so committing multi-megabyte
|
||||||
|
# ELF files would only make the repository grow with every rebuild.
|
||||||
|
dist/
|
||||||
|
# The vendored copy is produced by `make vendor` from contracts/agent-policy.json.
|
||||||
|
internal/policy/agent-policy.json.tmp
|
||||||
|
internal/install/msa-agent.service.tmp
|
||||||
|
# A binary left behind by `go build ./...` run in this directory.
|
||||||
|
msa-agent
|
||||||
|
*.tmp
|
||||||
|
.DS_Store
|
||||||
@@ -1,20 +1,42 @@
|
|||||||
POLICY_SRC := ../contracts/agent-policy.json
|
# This repository owns the authorisation surface: the agent is what enforces it, so it is not a copy of
|
||||||
|
# a file that lives somewhere else. `contracts/agent-policy.json` is embedded in the binary and is the
|
||||||
|
# source of truth; the relay vendors a copy and the two are compared at runtime, by the policy digest the
|
||||||
|
# agent reports in its handshake.
|
||||||
|
POLICY_SRC := contracts/agent-policy.json
|
||||||
POLICY_DST := internal/policy/agent-policy.json
|
POLICY_DST := internal/policy/agent-policy.json
|
||||||
VERSION ?= 0.1.0
|
# The systemd unit is embedded in the binary so that `msa-agent install` needs no route to anything but
|
||||||
|
# the relay, and so the unit a node runs is the one its agent was built with. Vendored mechanically: an
|
||||||
|
# operator editing the copy under internal/ would be editing a file the build overwrites, and
|
||||||
|
# TestVendoredUnitMatchesSystemd makes that loud.
|
||||||
|
UNIT_SRC := systemd/msa-agent.service
|
||||||
|
UNIT_DST := internal/install/msa-agent.service
|
||||||
|
# The version stamped into the binary, and the tag scripts/release.sh publishes under. It is the
|
||||||
|
# agent's own version rather than the app's: the two are released together but a node can be running an
|
||||||
|
# older agent than the phone, and the handshake's `agentBuild` field is what makes that visible.
|
||||||
|
VERSION ?= 1.0.0
|
||||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||||
|
|
||||||
.PHONY: all vendor test vet build build-arm64 build-armv7 clean
|
.PHONY: all vendor test vet fmt vectors build build-arm64 build-armv7 clean
|
||||||
|
|
||||||
all: vet test build
|
all: vet test build
|
||||||
|
|
||||||
|
# Regenerates contracts/test-vectors.json. It is the Go implementation that generates them, and the
|
||||||
|
# relay's Node reference implementation and the app's Swift tests recompute them from the result — three
|
||||||
|
# implementations with no shared code, so agreement is evidence rather than a tautology.
|
||||||
|
vectors:
|
||||||
|
@go run ./cmd/msa-vectors contracts/test-vectors.json
|
||||||
|
|
||||||
# The policy is embedded, so the vendored copy is part of the binary. Keep the copy mechanical:
|
# The policy is embedded, so the vendored copy is part of the binary. Keep the copy mechanical:
|
||||||
# an operator editing internal/policy/agent-policy.json by hand would be editing a file the build
|
# an operator editing internal/policy/agent-policy.json by hand would be editing a file the build
|
||||||
# overwrites, and the drift test makes that mistake loud rather than quiet.
|
# overwrites, and the drift test makes that mistake loud rather than quiet.
|
||||||
vendor: $(POLICY_DST)
|
vendor: $(POLICY_DST) $(UNIT_DST)
|
||||||
|
|
||||||
$(POLICY_DST): $(POLICY_SRC)
|
$(POLICY_DST): $(POLICY_SRC)
|
||||||
@cp $(POLICY_SRC) $(POLICY_DST)
|
@cp $(POLICY_SRC) $(POLICY_DST)
|
||||||
|
|
||||||
|
$(UNIT_DST): $(UNIT_SRC)
|
||||||
|
@cp $(UNIT_SRC) $(UNIT_DST)
|
||||||
|
|
||||||
test: vendor
|
test: vendor
|
||||||
@go test ./...
|
@go test ./...
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# msa-agent
|
||||||
|
|
||||||
|
Runs on a SimAdmin node and lets a paired iPhone reach it through a public relay, without a VPN and
|
||||||
|
without any inbound path into the internal network.
|
||||||
|
|
||||||
|
It opens no listening socket in normal operation. Every network action it takes is either an outbound
|
||||||
|
HTTPS long poll to the relay or a loopback request to the SimAdmin on the same machine, which is what
|
||||||
|
makes "no port mapping, no VPN" true rather than merely advertised.
|
||||||
|
|
||||||
|
```text
|
||||||
|
iPhone ──HTTPS(signed, encrypted envelope)──> relay <──outbound long poll── msa-agent ──HTTP──> 127.0.0.1:<port>
|
||||||
|
```
|
||||||
|
|
||||||
|
The relay is a dumb pipe: it cannot read a request, cannot forge an answer and cannot send a command.
|
||||||
|
The phone signs each request with its own private key and encrypts it to this agent's public key; this
|
||||||
|
agent verifies the signature before touching the payload and re-checks the policy before dialing
|
||||||
|
anything.
|
||||||
|
|
||||||
|
## Install on a node
|
||||||
|
|
||||||
|
The agent is a static binary with no runtime dependencies and no third-party modules. `enroll` mints
|
||||||
|
the node's keys and writes its config; `install` puts the systemd service around it.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo mkdir -p /opt/msa-agent /etc/msa-agent
|
||||||
|
sudo curl -fsSL https://gitea.chickliu.fun/Hermes/msa-agent/releases/latest/download/msa-agent-linux-arm64 \
|
||||||
|
-o /opt/msa-agent/msa-agent
|
||||||
|
sudo chmod 755 /opt/msa-agent/msa-agent
|
||||||
|
sudo /opt/msa-agent/msa-agent enroll --relay https://your-relay --port 3000 \
|
||||||
|
--code <one-time code> --write /etc/msa-agent/config.json
|
||||||
|
sudo /opt/msa-agent/msa-agent install
|
||||||
|
```
|
||||||
|
|
||||||
|
`armv7` is the same build for 32-bit ARM. `SHA256SUMS` covers both.
|
||||||
|
|
||||||
|
With `--code` the node registers itself with the relay and does not need an entry written into the
|
||||||
|
relay's `agents.json` by hand. Without it, `enroll` prints the entry to add.
|
||||||
|
|
||||||
|
Removing it again is `sudo msa-agent uninstall`; `--dry-run` shows the plan first, and `--keep-logs`
|
||||||
|
keeps the audit trail.
|
||||||
|
|
||||||
|
## Subcommands
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---|---|
|
||||||
|
| *(none)* | The daemon: handshake once, then poll forever |
|
||||||
|
| `enroll` | Mints keys and identifiers, writes the config, optionally registers with the relay |
|
||||||
|
| `install` | Creates the service user, gives it the config and logs, writes the unit, starts the service |
|
||||||
|
| `uninstall` | Tells the relay first, then removes the service, unit, config, logs and user |
|
||||||
|
| `version` | Prints the version stamped into the binary |
|
||||||
|
| `--pair` | Opens the temporary LAN window a phone uses to pair; closes itself |
|
||||||
|
| `--check` | Validates the config and policy, then exits |
|
||||||
|
| `--once` | Runs a single poll cycle and exits |
|
||||||
|
|
||||||
|
`install --print-unit` prints the systemd unit before installing it. The unit is embedded in the
|
||||||
|
binary rather than fetched, so a node cannot end up running a unit from a different release than its
|
||||||
|
agent.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| Path | Contents |
|
||||||
|
|---|---|
|
||||||
|
| `contracts/agent-policy.json` | The authorisation surface: read/write allowlists, loopback enforcement, limits. Embedded in the binary; this repository owns it |
|
||||||
|
| `contracts/test-vectors.json` | Deterministic vectors shared with the relay (Node) and the app (Swift), generated by `cmd/msa-vectors` |
|
||||||
|
| `internal/wire/` | The envelope: what is signed, what is encrypted, and in what order |
|
||||||
|
| `internal/policy/` | Loads and enforces the policy |
|
||||||
|
| `internal/runtime/` | Turns a verified envelope into one loopback call, or refuses it |
|
||||||
|
| `internal/egress/` | The only place a socket is opened toward SimAdmin |
|
||||||
|
| `internal/agent/` | The control loop: verify, decrypt, decide, execute, answer |
|
||||||
|
| `internal/pair/` | The temporary pairing window |
|
||||||
|
| `internal/enroll/` | Key and identifier generation, and self-registration with the relay |
|
||||||
|
| `internal/install/` | The service lifecycle: user, ownership, unit, and its reverse |
|
||||||
|
| `internal/transport/` | The outbound long-poll client |
|
||||||
|
| `systemd/` | The unit file, vendored into the binary by `make vendor` |
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make test # go test ./... (vendors first)
|
||||||
|
make vet
|
||||||
|
go test ./internal/agent -fuzz FuzzHandleDoesNotPanicOnArbitraryEnvelopes
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent is the one component whose input comes from a party the design assumes is hostile — the
|
||||||
|
relay — so `Handle` is fuzzed as well as table-tested. A panic there is not a bad request: the agent
|
||||||
|
is a single process holding one poll, so an unrecovered panic ends it and the node goes offline until
|
||||||
|
systemd restarts it.
|
||||||
|
|
||||||
|
Three properties are worth stating because they are what the tests are about, and each one was a real
|
||||||
|
defect at some point:
|
||||||
|
|
||||||
|
- **Ordering.** Nothing that depends on the payload happens before the signature verifies, and nothing
|
||||||
|
that touches SimAdmin happens before the policy says yes. Every refusal is classified, because a node
|
||||||
|
with a drifted clock and a node under attack produce the same outward symptom.
|
||||||
|
- **The limiters are sliding windows.** "At most N dangerous writes in any 60 seconds" is the property;
|
||||||
|
a fixed window admits 2N across its boundary, and a token bucket admits a burst after any quiet
|
||||||
|
period. The relay's per-device budget is the same algorithm, and the two are kept in step because
|
||||||
|
they bound the same operation from two sides.
|
||||||
|
- **A request the phone has given up on is not executed.** The relay answers 504 with "it may or may not
|
||||||
|
have executed it", and that is only honest while it stays true. A batch is processed serially, so the
|
||||||
|
last envelope of a full mailbox can begin well after its deadline; executing it then turns an honest
|
||||||
|
unknown into a duplicate SMS.
|
||||||
|
|
||||||
|
## Cross-repository contracts
|
||||||
|
|
||||||
|
This repository owns the two contract files. The relay keeps vendored copies:
|
||||||
|
|
||||||
|
- `agent-policy.json` — the relay derives its envelope ceiling, mailbox depth, request deadline and
|
||||||
|
long-poll window from these numbers, so the two cannot drift into disagreeing about what a node will
|
||||||
|
accept. `scripts/sync-contracts.sh` in the relay repository pulls the copy.
|
||||||
|
- `test-vectors.json` — generated here by `make vectors` (or `go run ./cmd/msa-vectors`), recomputed by
|
||||||
|
the relay's Node reference implementation and by the app's Swift tests.
|
||||||
|
|
||||||
|
Drift in the policy is visible at runtime rather than only in a test: the agent sends its policy digest
|
||||||
|
in the handshake, the relay compares it with its own copy and reports `policyMatches` per node in
|
||||||
|
`/v1/status`.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
No license file yet: until one is added, the default is all rights reserved. The source is public so
|
||||||
|
the binaries can be fetched by a node that has no credentials — the install command downloads from
|
||||||
|
this repository's releases — not as an invitation to redistribute.
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fun.chick/msa-agent/internal/config"
|
||||||
|
"fun.chick/msa-agent/internal/enroll"
|
||||||
|
"fun.chick/msa-agent/internal/install"
|
||||||
|
"fun.chick/msa-agent/internal/policy"
|
||||||
|
"fun.chick/msa-agent/internal/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnrollOptions is what `msa-agent enroll` was asked to do.
|
||||||
|
type EnrollOptions struct {
|
||||||
|
ConfigPath string
|
||||||
|
RelayURL string
|
||||||
|
LocalPort int
|
||||||
|
AuditPath string
|
||||||
|
NodeID string
|
||||||
|
AgentToken string
|
||||||
|
ProvisionSecret string
|
||||||
|
RelayCAFile string
|
||||||
|
NodePassword string
|
||||||
|
// Code is a one-time enrollment code. When set, the node registers itself with the relay and does
|
||||||
|
// not print a registration entry for anyone to copy.
|
||||||
|
Code string
|
||||||
|
// Force overwrites an existing config. Without it, an existing file is a refusal rather than an
|
||||||
|
// overwrite, because the file holds the node's private keys: replacing it silently would unpair
|
||||||
|
// every phone and orphan the node at the relay.
|
||||||
|
Force bool
|
||||||
|
// PrintOnly mints and prints without writing anything.
|
||||||
|
PrintOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// runEnroll mints this node's identity and writes its config, optionally registering with the relay.
|
||||||
|
//
|
||||||
|
// It is the on-node counterpart of `msa-keygen`, and both call internal/enroll, so the two cannot
|
||||||
|
// disagree about what a config looks like. The difference is where they run: msa-keygen needs a Go
|
||||||
|
// toolchain, which a node with a cross-compiled binary does not have.
|
||||||
|
func runEnroll(ctx context.Context, opts EnrollOptions) error {
|
||||||
|
if _, err := os.Stat(opts.ConfigPath); err == nil && !opts.Force && !opts.PrintOnly {
|
||||||
|
return fmt.Errorf("%s already exists; pass --force to replace it (this discards the node's keys "+
|
||||||
|
"and unpairs every phone)", opts.ConfigPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := enroll.Generate(enroll.Options{
|
||||||
|
// The flag is what records the node's own provenance: with a code the relay holds a
|
||||||
|
// self-enrolled entry for it, without one an operator wrote it into agents.json.
|
||||||
|
SelfEnrolled: opts.Code != "",
|
||||||
|
RelayURL: opts.RelayURL,
|
||||||
|
LocalPort: opts.LocalPort,
|
||||||
|
AuditPath: opts.AuditPath,
|
||||||
|
NodeID: opts.NodeID,
|
||||||
|
AgentToken: opts.AgentToken,
|
||||||
|
ProvisionSecret: opts.ProvisionSecret,
|
||||||
|
RelayCAFile: opts.RelayCAFile,
|
||||||
|
NodePassword: opts.NodePassword,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.PrintOnly {
|
||||||
|
os.Stdout.Write(result.Config)
|
||||||
|
fmt.Fprintf(os.Stderr, "\n# relay registration\n%s\n", result.Registration)
|
||||||
|
fmt.Fprintf(os.Stderr, "# pairing secret: %s\n", result.ProvisionSecret)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The registration goes first, and that order is the point of this command: a config written for a
|
||||||
|
// relay that does not know the node is a node that retries 401 forever, and an operator has no way
|
||||||
|
// to tell that apart from a network problem.
|
||||||
|
if opts.Code != "" {
|
||||||
|
var registration enroll.Registration
|
||||||
|
if err := json.Unmarshal(result.Registration, ®istration); err != nil {
|
||||||
|
return fmt.Errorf("enroll: decoding the registration: %w", err)
|
||||||
|
}
|
||||||
|
if err := enroll.Register(ctx, enroll.RegisterOptions{
|
||||||
|
RelayURL: opts.RelayURL,
|
||||||
|
Code: opts.Code,
|
||||||
|
Registration: registration,
|
||||||
|
RelayCAFile: opts.RelayCAFile,
|
||||||
|
}); err != nil {
|
||||||
|
// Nothing has been written, so a retry with a fresh code is safe and the node is untouched.
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slog.Info("relay accepted the enrollment", "node", result.NodeID, "relay", opts.RelayURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(opts.ConfigPath), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 0600: private keys, the relay bearer, and the node password when the node is protected.
|
||||||
|
if err := os.WriteFile(opts.ConfigPath, result.Config, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("node id: %s\n", result.NodeID)
|
||||||
|
fmt.Printf("wrote config: %s (mode 0600)\n", opts.ConfigPath)
|
||||||
|
if opts.Code == "" {
|
||||||
|
// Without a code the relay still has to be told by hand, so the entry is printed for copying.
|
||||||
|
fmt.Printf("\n# the relay does not know this node yet. Add this to its agents.json:\n%s\n", result.Registration)
|
||||||
|
}
|
||||||
|
fmt.Printf("\npairing secret: %s\n", result.ProvisionSecret)
|
||||||
|
fmt.Printf(" (only needed on an unprotected node; a node with nodePassword uses that instead)\n")
|
||||||
|
fmt.Printf("\nnext: systemctl enable --now msa-agent, then `msa-agent --pair` once and pair from the app\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runUninstall removes the agent from this node.
|
||||||
|
//
|
||||||
|
// The relay is told first, through the same mechanism a revocation uses: the device list is emptied and
|
||||||
|
// the relay is told directly, so it retires this node's device bearers before the node stops existing.
|
||||||
|
// Removing the node first would leave the relay routing to a node that is gone.
|
||||||
|
func runUninstall(ctx context.Context, opts install.Options, configPath string) error {
|
||||||
|
// A dry run needs no privileges: it reads nothing and removes nothing, and an operator checking
|
||||||
|
// what an uninstall would do should not have to be root to ask.
|
||||||
|
if !opts.DryRun {
|
||||||
|
if err := install.RequireRoot(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paths := install.DefaultPaths()
|
||||||
|
// The config path is a flag, so it can differ from the layout the defaults assume.
|
||||||
|
paths.ConfigPath = configPath
|
||||||
|
opts.Paths = paths
|
||||||
|
|
||||||
|
if opts.NotifyRelay == nil && !opts.DryRun {
|
||||||
|
opts.NotifyRelay = func() error { return retireAtRelay(ctx, configPath) }
|
||||||
|
}
|
||||||
|
if opts.RemovalHint == "" {
|
||||||
|
opts.RemovalHint = removalHint(configPath)
|
||||||
|
}
|
||||||
|
return install.Uninstall(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// retireAtRelay empties the paired-device list and tells the relay, so it drops this node's bearers.
|
||||||
|
//
|
||||||
|
// It talks to the relay itself rather than waiting for the running agent to notice. The agent does
|
||||||
|
// notice — it re-reads its config every poll cycle — but a poll window is 24 seconds, and making an
|
||||||
|
// uninstall wait on one, with a timeout, to learn something it can say in one round trip is a worse
|
||||||
|
// trade than holding a session for two requests.
|
||||||
|
//
|
||||||
|
// The order matters: the file is cleared first, so an agent that notices mid-way pushes the same empty
|
||||||
|
// set this function does. Both converge, and neither can resurrect a device.
|
||||||
|
func retireAtRelay(ctx context.Context, configPath string) error {
|
||||||
|
cfg, err := config.LoadForPairing(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading %s: %w", configPath, err)
|
||||||
|
}
|
||||||
|
if len(cfg.Devices) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cleared := cfg
|
||||||
|
cleared.Devices = nil
|
||||||
|
if err := config.Save(configPath, cleared); err != nil {
|
||||||
|
return fmt.Errorf("clearing the device list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sign, err := decodeKey(cfg.SignKey, ed25519.PrivateKeySize, "signKey")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
roots, err := relayRoots(cfg.RelayCAFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
client, err := transport.New(transport.Config{
|
||||||
|
BaseURL: cfg.RelayURL,
|
||||||
|
NodeID: cfg.NodeID,
|
||||||
|
Token: cfg.AgentToken,
|
||||||
|
Signer: sign,
|
||||||
|
UserAgent: "msa-agent/" + version,
|
||||||
|
RootCAs: roots,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pol, err := policy.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A short, bounded attempt: the uninstall must finish even when the relay is unreachable, and the
|
||||||
|
// caller prints what to do by hand in that case.
|
||||||
|
attemptCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := client.Hello(attemptCtx, version, pol.SHA256()); err != nil {
|
||||||
|
return fmt.Errorf("handshaking with the relay: %w", err)
|
||||||
|
}
|
||||||
|
if err := client.PushDevices(attemptCtx, nil); err != nil {
|
||||||
|
return fmt.Errorf("reporting an empty device set: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removalHint says what is left at the relay, which depends on how this node got there.
|
||||||
|
//
|
||||||
|
// The distinction matters because the two records live in different places and are removed by different
|
||||||
|
// people. A self-enrolled node is in the relay's own state file and comes off from the app; a node an
|
||||||
|
// operator wrote into agents.json is in a file the relay only reads, and has to be edited and the relay
|
||||||
|
// restarted. Telling the operator the wrong one sends them to the wrong place.
|
||||||
|
//
|
||||||
|
// An unreadable config is not a reason to fail an uninstall, so it falls back to naming both.
|
||||||
|
func removalHint(configPath string) string {
|
||||||
|
cfg, err := config.LoadForPairing(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return "the relay may still list this node; check its agents.json and, if it accepts self-enrollment, the app"
|
||||||
|
}
|
||||||
|
if cfg.SelfEnrolled {
|
||||||
|
return "the relay still lists this node in its enrollment state; remove it in the app (中继节点 → 移除)"
|
||||||
|
}
|
||||||
|
return "the relay's agents.json still lists this node; remove that entry and restart the relay"
|
||||||
|
}
|
||||||
+143
-4
@@ -15,6 +15,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -24,6 +25,7 @@ import (
|
|||||||
|
|
||||||
"fun.chick/msa-agent/internal/agent"
|
"fun.chick/msa-agent/internal/agent"
|
||||||
"fun.chick/msa-agent/internal/config"
|
"fun.chick/msa-agent/internal/config"
|
||||||
|
"fun.chick/msa-agent/internal/install"
|
||||||
"fun.chick/msa-agent/internal/pair"
|
"fun.chick/msa-agent/internal/pair"
|
||||||
"fun.chick/msa-agent/internal/policy"
|
"fun.chick/msa-agent/internal/policy"
|
||||||
"fun.chick/msa-agent/internal/runtime"
|
"fun.chick/msa-agent/internal/runtime"
|
||||||
@@ -41,12 +43,31 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Subcommands are dispatched before flag.Parse, because the default invocation is a bare
|
||||||
|
// `msa-agent --config ...` from systemd and a positional argument there would be a parse error.
|
||||||
|
// The two subcommands are the whole lifecycle around that daemon: put the node on the machine, and
|
||||||
|
// take it off again.
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "enroll":
|
||||||
|
os.Exit(runEnrollCommand(os.Args[2:]))
|
||||||
|
case "install":
|
||||||
|
os.Exit(runInstallCommand(os.Args[2:]))
|
||||||
|
case "uninstall":
|
||||||
|
os.Exit(runUninstallCommand(os.Args[2:]))
|
||||||
|
case "version":
|
||||||
|
fmt.Println(version)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
configPath = flag.String("config", "/etc/msa-agent/config.json", "path to the agent config file")
|
configPath = flag.String("config", "/etc/msa-agent/config.json", "path to the agent config file")
|
||||||
check = flag.Bool("check", false, "validate the config and policy, then exit")
|
check = flag.Bool("check", false, "validate the config and policy, then exit")
|
||||||
once = flag.Bool("once", false, "run a single poll cycle and exit")
|
once = flag.Bool("once", false, "run a single poll cycle and exit")
|
||||||
pairMode = flag.Bool("pair", false, "open the temporary pairing window on the LAN and exit when it closes")
|
pairMode = flag.Bool("pair", false, "open the temporary pairing window on the LAN and exit when it closes")
|
||||||
pairPort = flag.Int("pair-port", pair.DefaultAdminPort, "port for the pairing window's management listener")
|
pairPort = flag.Int("pair-port", pair.DefaultAdminPort, "port for the pairing window's management listener")
|
||||||
|
pairBind = flag.String("pair-bind", pair.DefaultAdminHost, "address the pairing window binds to")
|
||||||
)
|
)
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -55,7 +76,7 @@ func main() {
|
|||||||
|
|
||||||
var err error
|
var err error
|
||||||
if *pairMode {
|
if *pairMode {
|
||||||
err = runPair(*configPath, *pairPort)
|
err = runPair(*configPath, *pairPort, *pairBind)
|
||||||
} else {
|
} else {
|
||||||
err = run(*configPath, *check, *once)
|
err = run(*configPath, *check, *once)
|
||||||
}
|
}
|
||||||
@@ -71,7 +92,7 @@ func main() {
|
|||||||
// the invariant "the agent binds no socket" is a property of the daemon; the pairing window is a
|
// the invariant "the agent binds no socket" is a property of the daemon; the pairing window is a
|
||||||
// deliberate, operator-started exception with three independent shutdowns (first pairing, attempt
|
// deliberate, operator-started exception with three independent shutdowns (first pairing, attempt
|
||||||
// ceiling, deadline). Pairing is run again by the operator for each additional phone.
|
// ceiling, deadline). Pairing is run again by the operator for each additional phone.
|
||||||
func runPair(configPath string, adminPort int) error {
|
func runPair(configPath string, adminPort int, adminHost string) error {
|
||||||
cfg, err := config.LoadForPairing(configPath)
|
cfg, err := config.LoadForPairing(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -82,13 +103,19 @@ func runPair(configPath string, adminPort int) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if net.ParseIP(adminHost) == nil {
|
||||||
|
// A name here would be resolved at bind time, and the window is the one place the agent holds a
|
||||||
|
// listener: an operator who typed a hostname would get whatever that name resolved to, which is
|
||||||
|
// not a decision they made deliberately. The default is a literal for that reason.
|
||||||
|
return fmt.Errorf("pair-bind must be an IP literal, got %q", adminHost)
|
||||||
|
}
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: ":" + strconv.Itoa(adminPort),
|
Addr: net.JoinHostPort(adminHost, strconv.Itoa(adminPort)),
|
||||||
Handler: handler.Routes(),
|
Handler: handler.Routes(),
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
slog.Info("pairing window open", "port", adminPort, "node", cfg.NodeID)
|
slog.Info("pairing window open", "bind", adminHost, "port", adminPort, "node", cfg.NodeID)
|
||||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
slog.Error("pairing listener failed", "error", err)
|
slog.Error("pairing listener failed", "error", err)
|
||||||
handler.Close()
|
handler.Close()
|
||||||
@@ -371,3 +398,115 @@ func deviceSet(devices []config.Device) (agent.StaticDevices, error) {
|
|||||||
}
|
}
|
||||||
return set, nil
|
return set, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enrollFlags are the enrollment command's own flag set. It is separate from the daemon's so that
|
||||||
|
// `msa-agent enroll --help` describes only what enroll does, and so a flag added to one cannot
|
||||||
|
// silently change the other.
|
||||||
|
func runEnrollCommand(args []string) int {
|
||||||
|
flags := flag.NewFlagSet("enroll", flag.ExitOnError)
|
||||||
|
var opts EnrollOptions
|
||||||
|
flags.StringVar(&opts.ConfigPath, "write", "/etc/msa-agent/config.json", "where to write the agent config")
|
||||||
|
flags.StringVar(&opts.RelayURL, "relay", "", "relay base URL, https (required)")
|
||||||
|
flags.IntVar(&opts.LocalPort, "port", 3000, "the local SimAdmin port on 127.0.0.1")
|
||||||
|
flags.StringVar(&opts.AuditPath, "audit", "/var/log/msa-agent/audit.log", "audit log path")
|
||||||
|
flags.StringVar(&opts.NodeID, "node", "", "node id (nd_...); generated when empty")
|
||||||
|
flags.StringVar(&opts.AgentToken, "token", "", "relay bearer for this agent; generated when empty")
|
||||||
|
flags.StringVar(&opts.ProvisionSecret, "provision-secret", "", "pairing secret; generated when empty")
|
||||||
|
flags.StringVar(&opts.RelayCAFile, "relay-ca", "", "PEM bundle to pin as the relay's CA (optional)")
|
||||||
|
flags.StringVar(&opts.NodePassword, "node-password", "", "the node's SimAdmin password, if it has one")
|
||||||
|
flags.StringVar(&opts.Code, "code", "", "one-time enrollment code from the app; registers this node with the relay")
|
||||||
|
flags.BoolVar(&opts.Force, "force", false, "overwrite an existing config")
|
||||||
|
flags.BoolVar(&opts.PrintOnly, "print", false, "print the config instead of writing it")
|
||||||
|
flags.Usage = func() {
|
||||||
|
fmt.Fprintf(flags.Output(), "usage: msa-agent enroll --relay <https url> [--code <code>] [flags]\n\n")
|
||||||
|
fmt.Fprintf(flags.Output(), "Mints this node's keys and identifiers and writes its config. With --code it also\n")
|
||||||
|
fmt.Fprintf(flags.Output(), "registers itself with the relay, which is what makes the one-line install work.\n\n")
|
||||||
|
flags.PrintDefaults()
|
||||||
|
}
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if opts.RelayURL == "" {
|
||||||
|
flags.Usage()
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
if err := runEnroll(context.Background(), opts); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "msa-agent enroll:", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func runUninstallCommand(args []string) int {
|
||||||
|
flags := flag.NewFlagSet("uninstall", flag.ExitOnError)
|
||||||
|
var opts install.Options
|
||||||
|
configPath := flags.String("config", "/etc/msa-agent/config.json", "path to the agent config file")
|
||||||
|
flags.BoolVar(&opts.KeepLogs, "keep-logs", false, "leave the audit log in place")
|
||||||
|
flags.BoolVar(&opts.DryRun, "dry-run", false, "print what would be removed and remove nothing")
|
||||||
|
flags.Usage = func() {
|
||||||
|
fmt.Fprintf(flags.Output(), "usage: msa-agent uninstall [--dry-run] [--keep-logs]\n\n")
|
||||||
|
fmt.Fprintf(flags.Output(), "Removes the agent: the relay is told first, then the service, unit, config\n")
|
||||||
|
fmt.Fprintf(flags.Output(), "directory, logs and system user. Needs root.\n\n")
|
||||||
|
flags.PrintDefaults()
|
||||||
|
}
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
opts.Log = func(format string, args ...any) { fmt.Printf(format+"\n", args...) }
|
||||||
|
if err := runUninstall(context.Background(), opts, *configPath); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "msa-agent uninstall:", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// runInstallCommand makes a node ready to run the agent as a service.
|
||||||
|
//
|
||||||
|
// It is the second half of the install: `enroll` puts the keys and the config on the node, and this
|
||||||
|
// puts the service around them. Both are needed, and the first version of the install command had only
|
||||||
|
// the first — it left three things undone, each of which fails on the node: the service user was never
|
||||||
|
// created, the config stayed root-owned 0600 so the service could not read its own keys, and the unit
|
||||||
|
// was copied from a path in the repository that a node does not have.
|
||||||
|
func runInstallCommand(args []string) int {
|
||||||
|
flags := flag.NewFlagSet("install", flag.ExitOnError)
|
||||||
|
var opts install.InstallOptions
|
||||||
|
configPath := flags.String("config", "/etc/msa-agent/config.json", "path to the agent config file")
|
||||||
|
flags.BoolVar(&opts.DryRun, "dry-run", false, "print what would be done and change nothing")
|
||||||
|
printUnit := flags.Bool("print-unit", false, "print the systemd unit this binary installs, then exit")
|
||||||
|
flags.Usage = func() {
|
||||||
|
fmt.Fprintf(flags.Output(), "usage: msa-agent install [--dry-run] [--print-unit]\n\n")
|
||||||
|
fmt.Fprintf(flags.Output(), "Creates the service user, gives it the config and the log directory, writes the\n")
|
||||||
|
fmt.Fprintf(flags.Output(), "systemd unit from this binary, and starts the service. Run `enroll` first: this\n")
|
||||||
|
fmt.Fprintf(flags.Output(), "needs the config that enroll writes. Needs root.\n\n")
|
||||||
|
flags.PrintDefaults()
|
||||||
|
}
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if *printUnit {
|
||||||
|
// The unit is embedded, so this is how an operator reads it before it is installed — which is the
|
||||||
|
// point of embedding rather than fetching: the file that will be installed is readable first.
|
||||||
|
if err := install.PrintUnit(os.Stdout); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "msa-agent install:", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if !opts.DryRun {
|
||||||
|
if err := install.RequireRoot(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "msa-agent install:", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paths := install.DefaultPaths()
|
||||||
|
paths.ConfigPath = *configPath
|
||||||
|
opts.Paths = paths
|
||||||
|
opts.Log = func(format string, args ...any) { fmt.Printf(format+"\n", args...) }
|
||||||
|
if err := install.Install(opts); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "msa-agent install:", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|||||||
+24
-90
@@ -1,29 +1,22 @@
|
|||||||
// Command msa-keygen mints the key material and identifiers a node needs, and prints a config file
|
// Command msa-keygen prints the key material, identifiers and secrets a node needs, as a config file
|
||||||
// skeleton to stdout.
|
// and a relay registration entry.
|
||||||
//
|
//
|
||||||
// It exists because the agent's config cannot be written by hand: the two private keys are raw
|
// It is a thin wrapper over internal/enroll, which is also what `msa-agent enroll` calls. The two must
|
||||||
// 32-byte values that must be base64, and a node id, an agent token and a device id all have to
|
// not drift: one writes the config an operator places, the other writes it on the node, and a
|
||||||
// match patterns the agent and the relay both enforce. An operator generating those with openssl and
|
// disagreement between them would show up as a node that pairs and then cannot authenticate.
|
||||||
// a text editor will eventually produce a file that fails at startup with a validation error whose
|
|
||||||
// cause is a character in the wrong place.
|
|
||||||
//
|
//
|
||||||
// It generates the *agent's* keys only. The phone's keys are generated on the phone, at pairing
|
// It generates the *agent's* keys only. The phone's keys are generated on the phone, at pairing time,
|
||||||
// time, and never leave it — a tool that could mint a device key would be a tool that can impersonate
|
// and never leave it — a tool that could mint a device key would be a tool that can impersonate a
|
||||||
// a phone.
|
// phone.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/ed25519"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"fun.chick/msa-agent/internal/wire"
|
"fun.chick/msa-agent/internal/enroll"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -38,95 +31,36 @@ func main() {
|
|||||||
)
|
)
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// The signing key is Ed25519 for challenge-response at the relay and for signing responses.
|
result, err := enroll.Generate(enroll.Options{
|
||||||
signingPublic, signingKey, err := ed25519.GenerateKey(rand.Reader)
|
RelayURL: *relayURL,
|
||||||
check(err)
|
LocalPort: *localPort,
|
||||||
// The key-agreement key is raw X25519: clamping is the curve library's job, and a stored key
|
AuditPath: *auditPath,
|
||||||
// that had already been clamped would be a second, non-standard encoding to keep in sync across
|
NodeID: *nodeID,
|
||||||
// three languages.
|
AgentToken: *agentToken,
|
||||||
kxKey := make([]byte, wire.KeyBytes)
|
ProvisionSecret: *secret,
|
||||||
_, err = rand.Read(kxKey)
|
|
||||||
check(err)
|
|
||||||
|
|
||||||
id := *nodeID
|
|
||||||
if id == "" {
|
|
||||||
id = "nd_" + randomHex(5)
|
|
||||||
}
|
|
||||||
token := *agentToken
|
|
||||||
if token == "" {
|
|
||||||
token = randomHex(32)
|
|
||||||
}
|
|
||||||
// The pairing secret is generated even though it is only needed for an unprotected node: a
|
|
||||||
// config without one cannot pair at all, and discovering that at pairing time means another trip
|
|
||||||
// to the node. When the node *is* protected, `nodePassword` takes precedence over this value, so
|
|
||||||
// carrying it is harmless.
|
|
||||||
pairingSecret := *secret
|
|
||||||
if pairingSecret == "" {
|
|
||||||
pairingSecret = randomHex(16)
|
|
||||||
}
|
|
||||||
|
|
||||||
config := map[string]any{
|
|
||||||
"nodeId": id,
|
|
||||||
"relayUrl": *relayURL,
|
|
||||||
"agentToken": token,
|
|
||||||
"localPort": *localPort,
|
|
||||||
"signKey": base64.StdEncoding.EncodeToString(signingKey),
|
|
||||||
"kxKey": base64.StdEncoding.EncodeToString(kxKey),
|
|
||||||
// The node password is intentionally absent: it is the operator's, and it is the one secret
|
|
||||||
// this tool must not invent. An unprotected node needs no value; a protected one needs the
|
|
||||||
// real password or every command fails with node_auth_failed.
|
|
||||||
"auditPath": *auditPath,
|
|
||||||
// Only used to authorise the pairing window on an unprotected node. Delete it after pairing
|
|
||||||
// if you like; nothing else reads it.
|
|
||||||
"provisionSecret": pairingSecret,
|
|
||||||
// Empty on purpose: devices are added by pairing (`msa-agent --pair`), which is the only
|
|
||||||
// path that should ever be able to introduce a trusted phone.
|
|
||||||
"devices": []any{},
|
|
||||||
}
|
|
||||||
|
|
||||||
encoded, err := json.MarshalIndent(config, "", " ")
|
|
||||||
check(err)
|
|
||||||
encoded = append(encoded, '\n')
|
|
||||||
|
|
||||||
// The relay registration carries the agent's *public* signing key as well as the bearer token.
|
|
||||||
// Both are required: the token is only an abuse-control handle, and the signature over the relay's
|
|
||||||
// challenge is what proves the caller holds the node's private key. A registration without the
|
|
||||||
// key makes the relay refuse to start rather than start and reject every node, which is the
|
|
||||||
// failure mode that shipped when the key was omitted.
|
|
||||||
registration, err := json.Marshal(map[string]string{
|
|
||||||
"nodeID": id,
|
|
||||||
"token": token,
|
|
||||||
"signKey": base64.StdEncoding.EncodeToString(signingPublic),
|
|
||||||
})
|
})
|
||||||
check(err)
|
check(err)
|
||||||
|
|
||||||
if *write == "" {
|
if *write == "" {
|
||||||
// The relay's own registration is printed alongside, because the two files have to agree on
|
// The relay's own registration is printed alongside, because the two files have to agree on the
|
||||||
// the node id and token and copying them by hand is where that agreement breaks.
|
// node id, the token and the signing key, and copying them by hand is where that agreement breaks.
|
||||||
fmt.Fprintf(os.Stderr, "# agent config (paste into %s, then add \"nodePassword\" if the node is protected)\n", "/etc/msa-agent/config.json")
|
fmt.Fprintf(os.Stderr, "# agent config (paste into %s, then add \"nodePassword\" if the node is protected)\n", "/etc/msa-agent/config.json")
|
||||||
os.Stdout.Write(encoded)
|
os.Stdout.Write(result.Config)
|
||||||
fmt.Fprintf(os.Stderr, "\n# relay registration: add this entry to MSA_AGENTS\n")
|
fmt.Fprintf(os.Stderr, "\n# relay registration: add this entry to MSA_AGENTS\n")
|
||||||
fmt.Fprintf(os.Stderr, "%s\n", registration)
|
fmt.Fprintf(os.Stderr, "%s\n", result.Registration)
|
||||||
fmt.Fprintf(os.Stderr, "\n# pairing secret (unprotected nodes only; protected ones use nodePassword): %s\n", pairingSecret)
|
fmt.Fprintf(os.Stderr, "\n# pairing secret (unprotected nodes only; protected ones use nodePassword): %s\n", result.ProvisionSecret)
|
||||||
fmt.Fprintf(os.Stderr, "# next: run `msa-agent --pair` on the node once, then pair the phone from the app\n")
|
fmt.Fprintf(os.Stderr, "# next: run `msa-agent --pair` on the node once, then pair the phone from the app\n")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(*write), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(*write), 0o755); err != nil {
|
||||||
check(err)
|
check(err)
|
||||||
}
|
}
|
||||||
// 0600: this file holds the agent's private keys and the node's password.
|
// 0600: this file holds the agent's private keys and, on a protected node, the node password.
|
||||||
if err := os.WriteFile(*write, encoded, 0o600); err != nil {
|
if err := os.WriteFile(*write, result.Config, 0o600); err != nil {
|
||||||
check(err)
|
check(err)
|
||||||
}
|
}
|
||||||
fmt.Printf("wrote %s (mode 0600)\n", *write)
|
fmt.Printf("wrote %s (mode 0600)\n", *write)
|
||||||
fmt.Printf("relay registration: %s\n", registration)
|
fmt.Printf("relay registration: %s\n", result.Registration)
|
||||||
}
|
|
||||||
|
|
||||||
func randomHex(bytes int) string {
|
|
||||||
raw := make([]byte, bytes)
|
|
||||||
_, err := rand.Read(raw)
|
|
||||||
check(err)
|
|
||||||
return hex.EncodeToString(raw)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func check(err error) {
|
func check(err error) {
|
||||||
|
|||||||
@@ -144,7 +144,9 @@ func main() {
|
|||||||
check(err)
|
check(err)
|
||||||
encoded = append(encoded, '\n')
|
encoded = append(encoded, '\n')
|
||||||
|
|
||||||
target := filepath.Join("..", "..", "contracts", "test-vectors.json")
|
// This repository owns the vectors: the Go implementation generates them, and the Node and Swift
|
||||||
|
// implementations recompute them from a vendored copy.
|
||||||
|
target := filepath.Join("contracts", "test-vectors.json")
|
||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 {
|
||||||
target = os.Args[1]
|
target = os.Args[1]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"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,
|
||||||
|
"agentUpstreamTimeoutSeconds": 20,
|
||||||
|
"maxRequestBytes": 262144,
|
||||||
|
"maxResponseBytes": 262144,
|
||||||
|
"requestTtlSeconds": 30,
|
||||||
|
"clockSkewSeconds": 120,
|
||||||
|
"replayCacheSize": 4096,
|
||||||
|
"mailboxDepth": 8
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
{
|
||||||
|
"generatedBy": "agent/cmd/msa-vectors",
|
||||||
|
"note": "Deterministic cross-language vectors. The Node relay (test/wire.test.js) and the Swift app recompute these from this file; a mismatch means the envelope encoding drifted, not that one side is broken.",
|
||||||
|
"suite": "X25519-ED25519-AES256GCM-HKDFSHA256",
|
||||||
|
"version": 1,
|
||||||
|
"canonical": {
|
||||||
|
"comment": "Length-prefixed canonical bytes, then the deterministic Ed25519 signature over them. ek and p are raw decoded bytes, never their base64 text.",
|
||||||
|
"input": {
|
||||||
|
"v": 1,
|
||||||
|
"node": "nd_7f3a9c2e",
|
||||||
|
"req": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
|
||||||
|
"app": "ap_5b1d",
|
||||||
|
"ts": 1727289600,
|
||||||
|
"ek": "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=",
|
||||||
|
"alg": "X25519-ED25519-AES256GCM-HKDFSHA256",
|
||||||
|
"p": "AAEC/v8="
|
||||||
|
},
|
||||||
|
"bytes": {
|
||||||
|
"request": "6d73612d72656c61792d76312d726571000000000800000000000000010000000b6e645f37663361396332650000002433663235303465302d346638392d343164332d396130632d3033303565383263333330310000000761705f35623164000000080000000066f45900000000208520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a00000005000102feff",
|
||||||
|
"response": "6d73612d72656c61792d76312d72657370000000000800000000000000010000000b6e645f37663361396332650000002433663235303465302d346638392d343164332d396130632d303330356538326333333031000000080000000066f45900000000208520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a00000005000102feff"
|
||||||
|
},
|
||||||
|
"signature": {
|
||||||
|
"request": "f91fc4a40c4cb19643515894dba14a1baae602d0b307feafcf441a2d64690b94ac10e20982f8f3e8d2b48c74db11d250344256af92f7c22fa1b2c2fb6975fb0f",
|
||||||
|
"response": "3977dc4ad544ed83d9921be9929c49f5b38cd618583d99d10d096d3997237c02110da17ffebd45656453d94cd07044d2b1348e0c45381fa193bf5fb08b30b50e"
|
||||||
|
},
|
||||||
|
"publicKey": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
|
||||||
|
"responsePublicKey": "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"
|
||||||
|
},
|
||||||
|
"hkdf": {
|
||||||
|
"comment": "RFC 5869 test case 1, pinning the KDF itself rather than only its callers.",
|
||||||
|
"secret": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"salt": "000102030405060708090a0b0c",
|
||||||
|
"info": "f0f1f2f3f4f5f6f7f8f9",
|
||||||
|
"length": 42,
|
||||||
|
"output": "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865"
|
||||||
|
},
|
||||||
|
"x25519": {
|
||||||
|
"comment": "RFC 7748 section 6.1, then the derived per-direction key and IV for the envelope above. The IV is bound to the request id and the direction, so a key that is ever reused still cannot reuse a nonce.",
|
||||||
|
"appPrivate": "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a",
|
||||||
|
"agentPublic": "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f",
|
||||||
|
"sharedSecret": "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742",
|
||||||
|
"requestKey": "14f4f1823bd68aabccf326e5a4ee69891428491477bfc6949a25dd69bff1b1e9",
|
||||||
|
"requestIV": "e5be943a5b8ea89e34bbcb02",
|
||||||
|
"responseKey": "e350511cf24be4045c770180276146ec647283695394dec608f1695b5f07524e",
|
||||||
|
"responseIV": "44c5c0499337c5da62b463ad"
|
||||||
|
},
|
||||||
|
"aead": {
|
||||||
|
"comment": "AES-256-GCM under the request key above; the 16-byte tag is appended.",
|
||||||
|
"key": "14f4f1823bd68aabccf326e5a4ee69891428491477bfc6949a25dd69bff1b1e9",
|
||||||
|
"iv": "e5be943a5b8ea89e34bbcb02",
|
||||||
|
"plaintext": "7b226d223a22474554222c2270223a222f6170692f6865616c7468227d",
|
||||||
|
"ciphertext": "6601340c304905a8308894ab9d050d5e2ea083ee7ad43df01b9c3e79daead5321099e7203abd5985cf2ef12114"
|
||||||
|
},
|
||||||
|
"intent": {
|
||||||
|
"comment": "sha256(method | path | canonical body). The agent recomputes it from the request it is about to forward, so a relay cannot edit a confirmed write.",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/sms/send",
|
||||||
|
"body": "{\"phone_number\":\"+8613800000000\",\"content\":\"hello\"}",
|
||||||
|
"digest": "d0f584d844cf6e25d5b233a65e86fd80b82a396836cb80522e528b0f6ea5ac00"
|
||||||
|
},
|
||||||
|
"canonicalJson": [
|
||||||
|
{
|
||||||
|
"input": "{\"b\":1,\"a\":2}",
|
||||||
|
"output": "{\"a\":2,\"b\":1}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": "[3,1,2]",
|
||||||
|
"output": "[3,1,2]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": " null ",
|
||||||
|
"output": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": "true",
|
||||||
|
"output": "true"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": "\"\u003c\u0026\u003e\"",
|
||||||
|
"output": "\"\u003c\u0026\u003e\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": "{\"x\":100}",
|
||||||
|
"output": "{\"x\":100}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": "{\"a\":{\"c\":[1,{\"b\":2,\"a\":1}],\"b\":0},\"A\":9}",
|
||||||
|
"output": "{\"A\":9,\"a\":{\"b\":0,\"c\":[1,{\"a\":1,\"b\":2}]}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": "{\"phone_number\":\"+8613800000000\",\"content\":\"hello\"}",
|
||||||
|
"output": "{\"content\":\"hello\",\"phone_number\":\"+8613800000000\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": "{\"content\":\"a\u2028b\"}",
|
||||||
|
"output": "{\"content\":\"a\u2028b\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": "{\"content\":\"a\u2029b\"}",
|
||||||
|
"output": "{\"content\":\"a\u2029b\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": "{\"content\":\"😀\"}",
|
||||||
|
"output": "{\"content\":\"😀\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": "{\"content\":\"\\b\\f\\u0001\"}",
|
||||||
|
"output": "{\"content\":\"\\b\\f\\u0001\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": "{\"content\":\"\\\\u2028\"}",
|
||||||
|
"output": "{\"content\":\"\\\\u2028\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": "{\"é\":1,\"z\":2}",
|
||||||
|
"output": "{\"z\":2,\"é\":1}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": "{\"𐀀\":1,\"�\":2}",
|
||||||
|
"output": "{\"�\":2,\"𐀀\":1}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"canonicalJsonRejected": [
|
||||||
|
"{\"content\":\"\\ud800\"}",
|
||||||
|
"{\"content\":\"\\udc00\"}",
|
||||||
|
"{\"content\":\"\\ud800\\ud800\"}",
|
||||||
|
"{\"content\":\"\\ud800x\"}"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -55,6 +55,14 @@ type File struct {
|
|||||||
// certificate. It exists for a relay behind a private CA; the default is the system store, which
|
// certificate. It exists for a relay behind a private CA; the default is the system store, which
|
||||||
// is what a public certificate needs. Setting it pins trust rather than disabling verification.
|
// is what a public certificate needs. Setting it pins trust rather than disabling verification.
|
||||||
RelayCAFile string `json:"relayCAFile,omitempty"`
|
RelayCAFile string `json:"relayCAFile,omitempty"`
|
||||||
|
// SelfEnrolled records that this node registered itself with the relay using a one-time code,
|
||||||
|
// rather than being written into the relay's agents.json by an operator.
|
||||||
|
//
|
||||||
|
// It exists so `uninstall` can tell the operator which of two removals applies, because the agent
|
||||||
|
// cannot do either: it holds no admin token, by design — a node is not an operator, and giving it
|
||||||
|
// one would let a compromised node remove its neighbours. So the node records which half of the
|
||||||
|
// relay's state mentions it, and the operator is told exactly what to delete.
|
||||||
|
SelfEnrolled bool `json:"selfEnrolled,omitempty"`
|
||||||
// AuditPath is where the audit log is appended.
|
// AuditPath is where the audit log is appended.
|
||||||
AuditPath string `json:"auditPath"`
|
AuditPath string `json:"auditPath"`
|
||||||
// Devices may be empty only in pair mode (--pair). In normal operation an agent with no
|
// Devices may be empty only in pair mode (--pair). In normal operation an agent with no
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// Package enroll mints the key material, identifiers and secrets a node needs, and renders the two
|
||||||
|
// artefacts an operator has to place: the agent's config file and the relay's registration entry.
|
||||||
|
//
|
||||||
|
// It exists as a package rather than as a command because two callers need the same thing and must
|
||||||
|
// not drift. `msa-keygen` was the first, and it required a Go toolchain on whichever machine ran it —
|
||||||
|
// fine when that was the operator's laptop, wrong now that enrollment happens on the node itself,
|
||||||
|
// where there is a cross-compiled binary and no toolchain. `msa-agent enroll` is the second caller,
|
||||||
|
// and it is the same code path, so the two can never disagree about what a config looks like.
|
||||||
|
//
|
||||||
|
// What it deliberately does NOT do: invent the node's SimAdmin password. That is the operator's, and
|
||||||
|
// it is the one secret a tool must not make up. An unprotected node needs no value; a protected one
|
||||||
|
// needs the real one, or every command fails with node_auth_failed and the cause is not obvious.
|
||||||
|
package enroll
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"fun.chick/msa-agent/internal/wire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options are what the caller knows and cannot derive.
|
||||||
|
type Options struct {
|
||||||
|
// RelayURL is the relay this node will connect to. https; the agent refuses anything else.
|
||||||
|
RelayURL string
|
||||||
|
// LocalPort is the SimAdmin port on 127.0.0.1. There is deliberately no host.
|
||||||
|
LocalPort int
|
||||||
|
// AuditPath is where the audit log is appended.
|
||||||
|
AuditPath string
|
||||||
|
// NodeID, AgentToken and ProvisionSecret are generated when empty. They are settable so an
|
||||||
|
// operator re-enrolling a node can keep the identifiers the relay already knows.
|
||||||
|
NodeID string
|
||||||
|
AgentToken string
|
||||||
|
ProvisionSecret string
|
||||||
|
// RelayCAFile pins the relay's CA instead of the system trust store. Optional.
|
||||||
|
RelayCAFile string
|
||||||
|
// NodePassword is written into the config when non-empty. Never generated.
|
||||||
|
NodePassword string
|
||||||
|
// SelfEnrolled records that this node registered itself with a one-time code, so `uninstall` can
|
||||||
|
// tell the operator which of the relay's two records mentions it.
|
||||||
|
SelfEnrolled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is everything the caller has to place or show.
|
||||||
|
type Result struct {
|
||||||
|
// Config is the agent config file, ready to write 0600.
|
||||||
|
Config []byte
|
||||||
|
// Registration is the relay's agents.json entry, as compact JSON.
|
||||||
|
Registration []byte
|
||||||
|
// NodeID and ProvisionSecret are echoed because both have to be shown to the operator.
|
||||||
|
NodeID string
|
||||||
|
ProvisionSecret string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registration is the relay's view of this node: an id, a bearer token, and the public key that
|
||||||
|
// verifies its handshake signatures.
|
||||||
|
//
|
||||||
|
// The signing key is the part that cannot be omitted, and the reason is not tidiness. The relay
|
||||||
|
// refuses to start when an entry has no usable key — it did ship once with an optional verifier that
|
||||||
|
// nobody supplied, and the result was a relay that reported healthy and answered every node with 500.
|
||||||
|
type Registration struct {
|
||||||
|
NodeID string `json:"nodeID"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
SignKey string `json:"signKey"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate mints the material and renders both artefacts.
|
||||||
|
func Generate(opts Options) (Result, error) {
|
||||||
|
if opts.RelayURL == "" {
|
||||||
|
return Result{}, fmt.Errorf("enroll: relay URL is required")
|
||||||
|
}
|
||||||
|
if opts.LocalPort <= 0 || opts.LocalPort > 65535 {
|
||||||
|
return Result{}, fmt.Errorf("enroll: local port %d is out of range", opts.LocalPort)
|
||||||
|
}
|
||||||
|
if opts.AuditPath == "" {
|
||||||
|
return Result{}, fmt.Errorf("enroll: audit path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The signing key is Ed25519: it answers the relay's handshake challenge and signs responses.
|
||||||
|
signingPublic, signingKey, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("enroll: generating the signing key: %w", err)
|
||||||
|
}
|
||||||
|
// The key-agreement key is raw X25519. Clamping is the curve library's job, and a stored key that
|
||||||
|
// had already been clamped would be a second, non-standard encoding to keep in sync across three
|
||||||
|
// languages.
|
||||||
|
kxKey := make([]byte, wire.KeyBytes)
|
||||||
|
if _, err := rand.Read(kxKey); err != nil {
|
||||||
|
return Result{}, fmt.Errorf("enroll: generating the key-agreement key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeID := opts.NodeID
|
||||||
|
if nodeID == "" {
|
||||||
|
nodeID = "nd_" + randomHex(5)
|
||||||
|
}
|
||||||
|
token := opts.AgentToken
|
||||||
|
if token == "" {
|
||||||
|
token = randomHex(32)
|
||||||
|
}
|
||||||
|
// The pairing secret is minted even though an unprotected node is the only thing that needs it: a
|
||||||
|
// config without one cannot pair at all, and discovering that at pairing time means another trip to
|
||||||
|
// the node. When the node *is* protected, nodePassword takes precedence and this value is unused.
|
||||||
|
pairingSecret := opts.ProvisionSecret
|
||||||
|
if pairingSecret == "" {
|
||||||
|
pairingSecret = randomHex(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
config := map[string]any{
|
||||||
|
"nodeId": nodeID,
|
||||||
|
"relayUrl": opts.RelayURL,
|
||||||
|
"agentToken": token,
|
||||||
|
"localPort": opts.LocalPort,
|
||||||
|
"signKey": base64.StdEncoding.EncodeToString(signingKey),
|
||||||
|
"kxKey": base64.StdEncoding.EncodeToString(kxKey),
|
||||||
|
"auditPath": opts.AuditPath,
|
||||||
|
// Only used to authorise the pairing window on an unprotected node. Deleting it after pairing
|
||||||
|
// is fine; nothing else reads it.
|
||||||
|
"provisionSecret": pairingSecret,
|
||||||
|
// Empty on purpose: devices are added by pairing (`msa-agent --pair`), which is the only path
|
||||||
|
// that should ever be able to introduce a trusted phone.
|
||||||
|
"devices": []any{},
|
||||||
|
}
|
||||||
|
// Absent rather than empty when unset, so a config that has not been told about a password does not
|
||||||
|
// claim the node has none.
|
||||||
|
if opts.NodePassword != "" {
|
||||||
|
config["nodePassword"] = opts.NodePassword
|
||||||
|
}
|
||||||
|
if opts.RelayCAFile != "" {
|
||||||
|
config["relayCAFile"] = opts.RelayCAFile
|
||||||
|
}
|
||||||
|
if opts.SelfEnrolled {
|
||||||
|
config["selfEnrolled"] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := json.MarshalIndent(config, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("enroll: encoding the config: %w", err)
|
||||||
|
}
|
||||||
|
encoded = append(encoded, '\n')
|
||||||
|
|
||||||
|
registration, err := json.Marshal(Registration{
|
||||||
|
NodeID: nodeID,
|
||||||
|
Token: token,
|
||||||
|
SignKey: base64.StdEncoding.EncodeToString(signingPublic),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("enroll: encoding the registration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result{
|
||||||
|
Config: encoded,
|
||||||
|
Registration: registration,
|
||||||
|
NodeID: nodeID,
|
||||||
|
ProvisionSecret: pairingSecret,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomHex(bytes int) string {
|
||||||
|
raw := make([]byte, bytes)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
// rand.Read only fails if the system entropy source is gone, in which case nothing here can run
|
||||||
|
// safely: a key minted from a broken source is worse than no key.
|
||||||
|
panic(fmt.Sprintf("enroll: entropy source failed: %v", err))
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(raw)
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package enroll
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The two artefacts have to agree, and that agreement is the whole reason this is a package rather
|
||||||
|
// than two commands: the config carries the private signing key, the registration carries the public
|
||||||
|
// one, and a node whose relay entry was minted from a different key pair authenticates nowhere.
|
||||||
|
func TestTheConfigAndTheRegistrationDescribeTheSameNode(t *testing.T) {
|
||||||
|
result, err := Generate(Options{
|
||||||
|
RelayURL: "https://relay.example",
|
||||||
|
LocalPort: 3000,
|
||||||
|
AuditPath: "/var/log/msa-agent/audit.log",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg struct {
|
||||||
|
NodeID string `json:"nodeId"`
|
||||||
|
RelayURL string `json:"relayUrl"`
|
||||||
|
AgentToken string `json:"agentToken"`
|
||||||
|
LocalPort int `json:"localPort"`
|
||||||
|
SignKey string `json:"signKey"`
|
||||||
|
KxKey string `json:"kxKey"`
|
||||||
|
AuditPath string `json:"auditPath"`
|
||||||
|
Devices []any `json:"devices"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(result.Config, &cfg); err != nil {
|
||||||
|
t.Fatalf("the config is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
var reg Registration
|
||||||
|
if err := json.Unmarshal(result.Registration, ®); err != nil {
|
||||||
|
t.Fatalf("the registration is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.NodeID != reg.NodeID || cfg.NodeID != result.NodeID {
|
||||||
|
t.Fatalf("node ids disagree: config=%q registration=%q result=%q", cfg.NodeID, reg.NodeID, result.NodeID)
|
||||||
|
}
|
||||||
|
if cfg.AgentToken != reg.Token {
|
||||||
|
t.Fatal("the config's bearer and the registration's token differ; the agent would 401")
|
||||||
|
}
|
||||||
|
if cfg.RelayURL != "https://relay.example" || cfg.LocalPort != 3000 {
|
||||||
|
t.Fatalf("relay or port lost: %+v", cfg)
|
||||||
|
}
|
||||||
|
if cfg.Devices == nil || len(cfg.Devices) != 0 {
|
||||||
|
t.Fatalf("devices must start empty, got %v", cfg.Devices)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The private key is 64 bytes; its last 32 are the public key, which is what the relay verifies.
|
||||||
|
private, err := base64.StdEncoding.DecodeString(cfg.SignKey)
|
||||||
|
if err != nil || len(private) != ed25519.PrivateKeySize {
|
||||||
|
t.Fatalf("signKey is not a base64 ed25519 private key: %v (%d bytes)", err, len(private))
|
||||||
|
}
|
||||||
|
public, err := base64.StdEncoding.DecodeString(reg.SignKey)
|
||||||
|
if err != nil || len(public) != ed25519.PublicKeySize {
|
||||||
|
t.Fatalf("the registration's signKey is not a base64 ed25519 public key: %v (%d bytes)", err, len(public))
|
||||||
|
}
|
||||||
|
if string(private[ed25519.PrivateKeySize-ed25519.PublicKeySize:]) != string(public) {
|
||||||
|
t.Fatal("the relay's registration carries a key that does not belong to the config's private key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The node password is the one secret this tool must not invent. An unprotected node needs no value;
|
||||||
|
// a protected one needs the real one, or every command fails with node_auth_failed and the cause is
|
||||||
|
// not obvious from the phone.
|
||||||
|
func TestTheNodePasswordIsOnlyWrittenWhenGiven(t *testing.T) {
|
||||||
|
without, err := Generate(Options{RelayURL: "https://relay.example", LocalPort: 3000, AuditPath: "/a"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(without.Config), "nodePassword") {
|
||||||
|
t.Fatal("a config with no password claims one")
|
||||||
|
}
|
||||||
|
with, err := Generate(Options{
|
||||||
|
RelayURL: "https://relay.example", LocalPort: 3000, AuditPath: "/a", NodePassword: "hunter2",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(with.Config), `"nodePassword": "hunter2"`) {
|
||||||
|
t.Fatal("the given password was not written")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-enrolling a node has to be able to keep the identifiers the relay already knows, or the operator
|
||||||
|
// has two edits to make instead of one.
|
||||||
|
func TestGivenIdentifiersAreKept(t *testing.T) {
|
||||||
|
result, err := Generate(Options{
|
||||||
|
RelayURL: "https://relay.example", LocalPort: 3000, AuditPath: "/a",
|
||||||
|
NodeID: "nd_keepme0001", AgentToken: strings.Repeat("a", 64), ProvisionSecret: "kept-secret",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var reg Registration
|
||||||
|
if err := json.Unmarshal(result.Registration, ®); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if reg.NodeID != "nd_keepme0001" || reg.Token != strings.Repeat("a", 64) {
|
||||||
|
t.Fatalf("identifiers were regenerated: %+v", reg)
|
||||||
|
}
|
||||||
|
if result.ProvisionSecret != "kept-secret" {
|
||||||
|
t.Fatal("the pairing secret was regenerated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two enrollments must not produce the same keys or ids. A generator that repeated a key would make
|
||||||
|
// two nodes able to impersonate each other, and it is the kind of bug a seeded RNG introduces once.
|
||||||
|
func TestTwoEnrollmentsDiffer(t *testing.T) {
|
||||||
|
first, err := Generate(Options{RelayURL: "https://relay.example", LocalPort: 3000, AuditPath: "/a"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := Generate(Options{RelayURL: "https://relay.example", LocalPort: 3000, AuditPath: "/a"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first.NodeID == second.NodeID {
|
||||||
|
t.Fatal("two enrollments produced the same node id")
|
||||||
|
}
|
||||||
|
if string(first.Config) == string(second.Config) {
|
||||||
|
t.Fatal("two enrollments produced the same config, so the same keys")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMissingInputIsRefused(t *testing.T) {
|
||||||
|
cases := map[string]Options{
|
||||||
|
"no relay": {LocalPort: 3000, AuditPath: "/a"},
|
||||||
|
"bad port": {RelayURL: "https://relay.example", LocalPort: 0, AuditPath: "/a"},
|
||||||
|
"huge port": {RelayURL: "https://relay.example", LocalPort: 70000, AuditPath: "/a"},
|
||||||
|
"no audit": {RelayURL: "https://relay.example", LocalPort: 3000},
|
||||||
|
}
|
||||||
|
for name, opts := range cases {
|
||||||
|
if _, err := Generate(opts); err == nil {
|
||||||
|
t.Fatalf("%s was accepted", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The node records how it got into the relay, because `uninstall` cannot work it out any other way: the
|
||||||
|
// agent holds no admin token, so it cannot remove its own entry, and the operator has to be told which
|
||||||
|
// of the relay's two records mentions this node.
|
||||||
|
func TestEnrollmentRecordsItsOwnProvenance(t *testing.T) {
|
||||||
|
withCode, err := Generate(Options{
|
||||||
|
RelayURL: "https://relay.example", LocalPort: 3000, AuditPath: "/a", SelfEnrolled: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(withCode.Config), `"selfEnrolled": true`) {
|
||||||
|
t.Fatal("a self-enrolled node does not say so")
|
||||||
|
}
|
||||||
|
// And a node an operator wrote into agents.json does not claim to have enrolled itself, or the
|
||||||
|
// uninstall would send the operator to the app to remove an entry that is not there.
|
||||||
|
manual, err := Generate(Options{RelayURL: "https://relay.example", LocalPort: 3000, AuditPath: "/a"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(manual.Config), "selfEnrolled") {
|
||||||
|
t.Fatal("a manually registered node claims to be self-enrolled")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package enroll
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register announces this node to the relay, using a one-time code an operator generated in the app.
|
||||||
|
//
|
||||||
|
// This is the step that lets a node join without anyone editing the relay's agents.json, and it is the
|
||||||
|
// only place in this system where the relay learns about a node it did not already know. What the code
|
||||||
|
// buys is bounded and worth stating: it authorises *registration*, nothing else. The relay still cannot
|
||||||
|
// read a request, cannot forge an answer, and cannot command anything — the phone's signature and the
|
||||||
|
// agent's pinned keys are unchanged. The worst a stolen code can do is make a node appear, which is a
|
||||||
|
// denial of service, the outcome the design already accepts for a compromised relay.
|
||||||
|
type RegisterOptions struct {
|
||||||
|
// RelayURL is the relay's base URL, https.
|
||||||
|
RelayURL string
|
||||||
|
// Code is the one-time enrollment code.
|
||||||
|
Code string
|
||||||
|
// Registration is the entry to install: node id, bearer token, signing public key.
|
||||||
|
Registration Registration
|
||||||
|
// RelayCAFile pins a CA instead of the system trust store. Same meaning as the agent's own
|
||||||
|
// relayCAFile: it replaces the trust root rather than disabling verification.
|
||||||
|
RelayCAFile string
|
||||||
|
// Timeout bounds the whole exchange. A node with no route to the relay should fail in seconds, not
|
||||||
|
// hang the install.
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register posts the registration and returns nil when the relay accepted it.
|
||||||
|
//
|
||||||
|
// The failure vocabulary is deliberately small and each case names the next action, because this runs
|
||||||
|
// during an install and the operator is looking at one line of output.
|
||||||
|
func Register(ctx context.Context, opts RegisterOptions) error {
|
||||||
|
parsed, err := url.Parse(opts.RelayURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("enroll: relay url: %w", err)
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "https" {
|
||||||
|
// https is not negotiable here for the same reason the agent refuses it: a registration sent in
|
||||||
|
// the clear can be replayed by anyone who sees it, and the code is single-use.
|
||||||
|
return fmt.Errorf("enroll: relay url must be https, got %q", parsed.Scheme)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(opts.Code) == "" {
|
||||||
|
return fmt.Errorf("enroll: the enrollment code is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Registration
|
||||||
|
}{Code: opts.Code, Registration: opts.Registration})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("enroll: encoding the registration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := opts.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 20 * time.Second
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: timeout}
|
||||||
|
if opts.RelayCAFile != "" {
|
||||||
|
pem, err := os.ReadFile(opts.RelayCAFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("enroll: relayCAFile: %w", err)
|
||||||
|
}
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
if !pool.AppendCertsFromPEM(pem) {
|
||||||
|
return fmt.Errorf("enroll: relayCAFile %s contains no PEM certificates", opts.RelayCAFile)
|
||||||
|
}
|
||||||
|
// A fresh transport rather than a mutated DefaultTransport, so this does not change trust for
|
||||||
|
// anything else in the process.
|
||||||
|
client.Transport = &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := strings.TrimRight(opts.RelayURL, "/") + "/v1/enroll"
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("enroll: request: %w", err)
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("enroll: cannot reach the relay at %s: %w", endpoint, err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
io.Copy(io.Discard, response.Body)
|
||||||
|
response.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if response.StatusCode == http.StatusOK {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// The relay's refusals are the ones an operator has to act on, so the detail is passed through
|
||||||
|
// rather than replaced with a status code.
|
||||||
|
detail := readDetail(response.Body)
|
||||||
|
switch response.StatusCode {
|
||||||
|
case http.StatusUnauthorized, http.StatusForbidden:
|
||||||
|
return fmt.Errorf("enroll: the relay refused the code (%s). Codes are single-use and short-lived; "+
|
||||||
|
"generate a new install command in the app", detail)
|
||||||
|
case http.StatusConflict:
|
||||||
|
return fmt.Errorf("enroll: the relay already knows this node (%s)", detail)
|
||||||
|
case http.StatusNotFound:
|
||||||
|
return fmt.Errorf("enroll: this relay does not accept self-enrollment (%s). Add this node to its "+
|
||||||
|
"agents.json by hand, or enable enrollment on the relay", detail)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("enroll: the relay refused the registration: %d %s", response.StatusCode, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readDetail pulls the RFC 9457 `detail` field out of a refusal, falling back to the raw body.
|
||||||
|
func readDetail(body io.Reader) string {
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(body, 4<<10))
|
||||||
|
if err != nil || len(raw) == 0 {
|
||||||
|
return "no detail"
|
||||||
|
}
|
||||||
|
var problem struct {
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &problem); err == nil && problem.Detail != "" {
|
||||||
|
if problem.Code != "" {
|
||||||
|
return problem.Code + ": " + problem.Detail
|
||||||
|
}
|
||||||
|
return problem.Detail
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(raw))
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=MultiSimAdmin relay agent
|
||||||
|
Documentation=https://gitea.chickliu.fun/Hermes/msa-agent
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/opt/msa-agent/msa-agent --config /etc/msa-agent/config.json
|
||||||
|
Restart=always
|
||||||
|
# Backing off on our own is not enough: a relay that is down for an hour should not be hammered by
|
||||||
|
# every node in the fleet the moment it comes back.
|
||||||
|
RestartSec=5
|
||||||
|
TimeoutStopSec=20
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
|
||||||
|
User=msa-agent
|
||||||
|
Group=msa-agent
|
||||||
|
|
||||||
|
# The agent needs almost nothing from the host. Everything below is a capability the process never
|
||||||
|
# uses, removed so that a compromise of the agent is not automatically a compromise of the box.
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateDevices=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
PrivateUsers=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectClock=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
ProtectHostname=yes
|
||||||
|
ProtectKernelLogs=yes
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
RestrictRealtime=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
LockPersonality=yes
|
||||||
|
MemoryDenyWriteExecute=yes
|
||||||
|
RemoveIPC=yes
|
||||||
|
UMask=0077
|
||||||
|
|
||||||
|
# Configuration and the audit log are the only writable paths. ReadWritePaths is what keeps
|
||||||
|
# ProtectSystem=strict from making the audit log unwritable.
|
||||||
|
ReadWritePaths=/var/log/msa-agent
|
||||||
|
ConfigurationDirectory=msa-agent
|
||||||
|
LogsDirectory=msa-agent
|
||||||
|
|
||||||
|
# AF_INET and AF_INET6 only: the agent dials out to the relay and to 127.0.0.1, so it has no reason
|
||||||
|
# to reach a Unix socket, a netlink family or a packet socket.
|
||||||
|
#
|
||||||
|
# No IPAddressDeny / IPAddressAllow pair here, deliberately. It reads like the obvious way to pin the
|
||||||
|
# agent to loopback, and it is wrong: those directives filter both directions, so IPAddressDeny=any
|
||||||
|
# would also cut the outbound connection to the relay the agent exists to maintain. The loopback
|
||||||
|
# invariant is enforced in code by internal/egress, which is testable, rather than by a directive
|
||||||
|
# that silently breaks the only path out.
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6
|
||||||
|
# No capability at all, so the process cannot even ask to bind a privileged port.
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
AmbientCapabilities=
|
||||||
|
SystemCallFilter=@system-service
|
||||||
|
SystemCallErrorNumber=EPERM
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// Package install puts the agent on a node and takes it off again, as the two halves of one sequence.
|
||||||
|
//
|
||||||
|
// It is one package because the two have to agree about every path. An install that creates something
|
||||||
|
// the uninstall does not remove leaves a machine with a stray credential on it, and an uninstall that
|
||||||
|
// removes something the install did not create fails on a fresh node. Keeping the path list in one
|
||||||
|
// place is what makes that agreement checkable rather than remembered.
|
||||||
|
//
|
||||||
|
// The systemd unit is embedded rather than fetched. Two reasons, and the second is the one that
|
||||||
|
// decided it: a node installing the agent may have no route to anything except the relay, and a unit
|
||||||
|
// file downloaded from a URL is a second thing that can 404 at the exact moment the operator has the
|
||||||
|
// least context to debug it. Embedding also means the unit an operator runs is the one the binary was
|
||||||
|
// built with, so a node cannot end up with a unit from a different release than its agent.
|
||||||
|
package install
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The vendored unit. It is copied from agent/systemd/ by the Makefile, and
|
||||||
|
// TestVendoredUnitMatchesSystemd fails when the copy drifts — the same arrangement the agent's policy
|
||||||
|
// uses, and for the same reason: an embedded file that is edited in place is a file the build
|
||||||
|
// overwrites, and the mistake should be loud rather than silent.
|
||||||
|
//
|
||||||
|
//go:embed msa-agent.service
|
||||||
|
var embeddedUnit []byte
|
||||||
|
|
||||||
|
// UnitFile returns the unit this binary installs, for tests and for `install --print-unit`.
|
||||||
|
func UnitFile() []byte { return embeddedUnit }
|
||||||
|
|
||||||
|
// InstallOptions configures an install.
|
||||||
|
type InstallOptions struct {
|
||||||
|
Paths Paths
|
||||||
|
// DryRun prints the plan and changes nothing.
|
||||||
|
DryRun bool
|
||||||
|
Runner Runner
|
||||||
|
Log func(format string, args ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install makes a node ready to run the agent as a service, assuming the binary is already in place
|
||||||
|
// and the config has already been written by `msa-agent enroll`.
|
||||||
|
//
|
||||||
|
// It is idempotent: every step checks what is already true, so re-running it after an upgrade or a
|
||||||
|
// failed attempt is safe. That matters because the alternative — a command that only works on a clean
|
||||||
|
// node — makes every partial failure a manual cleanup.
|
||||||
|
//
|
||||||
|
// The order is the reverse of the uninstall's, and for the same reason: the user has to exist before
|
||||||
|
// anything can be owned by it, and the config has to be readable by that user before the service can
|
||||||
|
// start.
|
||||||
|
func Install(opts InstallOptions) error {
|
||||||
|
if opts.Runner == nil {
|
||||||
|
opts.Runner = ExecRunner{}
|
||||||
|
}
|
||||||
|
if opts.Log == nil {
|
||||||
|
opts.Log = func(string, ...any) {}
|
||||||
|
}
|
||||||
|
paths := opts.Paths
|
||||||
|
|
||||||
|
// The two preconditions, reported rather than enforced during a dry run.
|
||||||
|
//
|
||||||
|
// A dry run is asked before the work is done — that is the whole point of it — so refusing it for
|
||||||
|
// the very conditions it exists to reveal would make it useless on exactly the machine an operator
|
||||||
|
// runs it on. The real run refuses, because a service whose ExecStart points at nothing fails with
|
||||||
|
// "no such file or directory" in the journal, which reads like a broken build.
|
||||||
|
binaryMissing := !exists(paths.BinaryPath)
|
||||||
|
configMissing := !exists(paths.ConfigPath)
|
||||||
|
if !opts.DryRun {
|
||||||
|
if binaryMissing {
|
||||||
|
return fmt.Errorf("install: %s is not there; run this from the installed binary, not a copy in /tmp", paths.BinaryPath)
|
||||||
|
}
|
||||||
|
if configMissing {
|
||||||
|
return fmt.Errorf("install: %s is not there; run `msa-agent enroll` first", paths.ConfigPath)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if binaryMissing {
|
||||||
|
opts.Log("note: %s is not there yet; the real run would refuse", paths.BinaryPath)
|
||||||
|
}
|
||||||
|
if configMissing {
|
||||||
|
opts.Log("note: %s is not there yet; run `msa-agent enroll` first", paths.ConfigPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. The system user. `--system` gives it no login shell and no home, which is what a process that
|
||||||
|
// only dials out needs; `--no-create-home` keeps the filesystem as it was.
|
||||||
|
if stepWith(opts.DryRun, opts.Log, "creating the %s system user", paths.User) {
|
||||||
|
if err := opts.Runner.Run("getent", "passwd", paths.User); err == nil {
|
||||||
|
opts.Log(" %s already exists", paths.User)
|
||||||
|
} else if err := opts.Runner.Run("useradd", "--system", "--no-create-home",
|
||||||
|
"--shell", "/usr/sbin/nologin", paths.User); err != nil {
|
||||||
|
return fmt.Errorf("install: creating the %s user: %w", paths.User, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. The log directory, owned by that user. The unit's ProtectSystem=strict makes everything else
|
||||||
|
// read-only, so this is one of the two paths the service can write.
|
||||||
|
if stepWith(opts.DryRun, opts.Log, "creating %s", paths.LogDir) {
|
||||||
|
if err := os.MkdirAll(paths.LogDir, 0o750); err != nil {
|
||||||
|
return fmt.Errorf("install: creating the log directory: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Ownership of the config, and of the log directory, and that step is load-bearing rather than
|
||||||
|
// tidy: `enroll` ran under sudo, so the config is root-owned 0600 and the service — which runs as
|
||||||
|
// the user above — cannot read its own private keys. The failure is a service that starts and
|
||||||
|
// immediately exits with a permission error.
|
||||||
|
if stepWith(opts.DryRun, opts.Log, "giving %s ownership of the config and logs", paths.User) {
|
||||||
|
for _, path := range []string{paths.ConfigPath, filepath.Dir(paths.ConfigPath), paths.LogDir} {
|
||||||
|
if err := chownTo(path, paths.User, opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. The unit, from this binary.
|
||||||
|
if stepWith(opts.DryRun, opts.Log, "writing %s", paths.UnitPath) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(paths.UnitPath), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("install: creating the unit directory: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(paths.UnitPath, embeddedUnit, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("install: writing the unit: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Enable and start. `daemon-reload` first, because systemd caches unit files and would otherwise
|
||||||
|
// start whatever it read last.
|
||||||
|
if stepWith(opts.DryRun, opts.Log, "reloading systemd") {
|
||||||
|
if err := opts.Runner.Run("systemctl", "daemon-reload"); err != nil {
|
||||||
|
return fmt.Errorf("install: systemctl daemon-reload: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stepWith(opts.DryRun, opts.Log, "enabling and starting %s", paths.ServiceName) {
|
||||||
|
if err := opts.Runner.Run("systemctl", "enable", "--now", paths.ServiceName); err != nil {
|
||||||
|
return fmt.Errorf("install: systemctl enable --now: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.DryRun {
|
||||||
|
opts.Log("dry run: nothing was changed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
opts.Log("msa-agent installed and running")
|
||||||
|
opts.Log("check it with: journalctl -u %s -n 20", paths.ServiceName)
|
||||||
|
opts.Log("next: run `msa-agent --pair` once, then pair the phone from the app")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// exists reports whether a path is present, without caring why it might not be.
|
||||||
|
func exists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// chownTo gives a path to the service user.
|
||||||
|
//
|
||||||
|
// It shells out to chown rather than calling os.Chown, and that is a deliberate consistency rather than
|
||||||
|
// a preference: the whole install is a sequence of system commands, so routing all of them through the
|
||||||
|
// Runner is what makes the sequence testable without root. os.Chown would work and would also be the one
|
||||||
|
// step a test could not intercept.
|
||||||
|
func chownTo(path, user string, opts InstallOptions) error {
|
||||||
|
if err := opts.Runner.Run("chown", user+":"+user, path); err != nil {
|
||||||
|
return fmt.Errorf("install: giving %s to %s: %w", path, user, err)
|
||||||
|
}
|
||||||
|
opts.Log(" %s -> %s", path, user)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintUnit writes the embedded unit to w, so an operator can read what will be installed before it is.
|
||||||
|
func PrintUnit(w io.Writer) error {
|
||||||
|
_, err := w.Write(embeddedUnit)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
// Package install removes the agent from a node, in the one order that leaves no dangling state.
|
||||||
|
//
|
||||||
|
// The order is the whole point, and every step of it was chosen against a specific failure:
|
||||||
|
//
|
||||||
|
// 1. Clear the paired-device list in the config and force one handshake, so the relay retires this
|
||||||
|
// node's device bearers. Removing the node first would leave the relay holding routing for a node
|
||||||
|
// that no longer exists — and, worse, leave a phone that thinks it is paired pointing at nothing.
|
||||||
|
// 2. Stop and disable the service. After this the agent cannot be reached by anything, including the
|
||||||
|
// relay, so the remaining steps are purely local.
|
||||||
|
// 3. Remove the unit file and reload systemd. Leaving a unit behind means a later `systemctl start`
|
||||||
|
// resurrects a node whose config is gone.
|
||||||
|
// 4. Remove the config directory. This holds the agent's private keys, the relay bearer and — on a
|
||||||
|
// protected node — the SimAdmin password. It is the step that actually matters for security, and
|
||||||
|
// it is why "uninstall" cannot be a shell one-liner the operator writes from memory.
|
||||||
|
// 5. Remove the log directory, unless asked to keep it. Audit entries are the only record of what
|
||||||
|
// this node was asked to do, so deleting them is a decision rather than a default.
|
||||||
|
// 6. Remove the system user. Last, because every step above ran as that user.
|
||||||
|
//
|
||||||
|
// Nothing here is reversible, so a dry run prints the plan and stops.
|
||||||
|
package install
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Paths is where the install put things. The defaults match agent/systemd/msa-agent.service and the
|
||||||
|
// deployment document; they are fields so a test can point them at a temporary directory.
|
||||||
|
type Paths struct {
|
||||||
|
ServiceName string
|
||||||
|
UnitPath string
|
||||||
|
ConfigPath string
|
||||||
|
LogDir string
|
||||||
|
BinaryPath string
|
||||||
|
User string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultPaths is the layout the deployment document describes.
|
||||||
|
func DefaultPaths() Paths {
|
||||||
|
return Paths{
|
||||||
|
ServiceName: "msa-agent",
|
||||||
|
UnitPath: "/etc/systemd/system/msa-agent.service",
|
||||||
|
ConfigPath: "/etc/msa-agent/config.json",
|
||||||
|
LogDir: "/var/log/msa-agent",
|
||||||
|
BinaryPath: "/opt/msa-agent/msa-agent",
|
||||||
|
User: "msa-agent",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner runs system commands. It is an interface so the sequence can be tested without systemd.
|
||||||
|
type Runner interface {
|
||||||
|
Run(name string, args ...string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecRunner runs commands for real.
|
||||||
|
type ExecRunner struct {
|
||||||
|
// Stdout and Stderr receive the output of commands that have any. systemctl is quiet on success.
|
||||||
|
Stdout io.Writer
|
||||||
|
Stderr io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r ExecRunner) Run(name string, args ...string) error {
|
||||||
|
command := exec.Command(name, args...)
|
||||||
|
command.Stdout = r.Stdout
|
||||||
|
command.Stderr = r.Stderr
|
||||||
|
return command.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options configures an uninstall.
|
||||||
|
type Options struct {
|
||||||
|
Paths Paths
|
||||||
|
// KeepLogs leaves the audit log in place. Deleting it is the default because the node is being
|
||||||
|
// removed, but an operator retiring a node after an incident may want the record.
|
||||||
|
KeepLogs bool
|
||||||
|
// DryRun prints what would happen and removes nothing.
|
||||||
|
DryRun bool
|
||||||
|
// NotifyRelay retires this node's device bearers at the relay, and is called before anything is
|
||||||
|
// removed. Nil skips it, which is right for a node that was never paired.
|
||||||
|
NotifyRelay func() error
|
||||||
|
// RemovalHint is the sentence printed at the end telling the operator what is left at the relay.
|
||||||
|
// It depends on how the node got there — self-enrolled or written into agents.json — and the caller
|
||||||
|
// is what knows that, from the config the node wrote at enrollment time.
|
||||||
|
RemovalHint string
|
||||||
|
Runner Runner
|
||||||
|
Log func(format string, args ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uninstall performs the sequence. It returns an error only for a failure that leaves the node in a
|
||||||
|
// state the operator must know about; a missing file or an already-stopped service is not one.
|
||||||
|
func Uninstall(opts Options) error {
|
||||||
|
if opts.Runner == nil {
|
||||||
|
opts.Runner = ExecRunner{}
|
||||||
|
}
|
||||||
|
if opts.Log == nil {
|
||||||
|
opts.Log = func(string, ...any) {}
|
||||||
|
}
|
||||||
|
paths := opts.Paths
|
||||||
|
|
||||||
|
// 1. The relay has to be told before the node stops existing.
|
||||||
|
if opts.NotifyRelay != nil {
|
||||||
|
if opts.DryRun {
|
||||||
|
opts.Log("would clear the paired-device list and tell the relay to retire this node's bearers")
|
||||||
|
} else if err := opts.NotifyRelay(); err != nil {
|
||||||
|
// Not fatal, but the operator has to know: the relay will keep routing to a node that is
|
||||||
|
// gone until its own restart or until the agent entry is removed from agents.json.
|
||||||
|
opts.Log("WARNING: could not tell the relay to retire this node: %v", err)
|
||||||
|
opts.Log(" remove this node's entry from the relay's agents.json by hand")
|
||||||
|
} else {
|
||||||
|
opts.Log("told the relay to retire this node's device bearers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Stop and disable. `disable --now` is one call so a failure cannot leave the unit enabled with
|
||||||
|
// the process still running.
|
||||||
|
//
|
||||||
|
// Every step below is gated on step()'s answer, and that is load-bearing rather than tidy: a dry
|
||||||
|
// run that stops the service is not a dry run. The first version of this file logged the plan and
|
||||||
|
// ran the commands anyway, which on a Linux box would have taken the node offline while telling the
|
||||||
|
// operator nothing had been touched.
|
||||||
|
if step(opts, "stopping and disabling %s", paths.ServiceName) {
|
||||||
|
if err := opts.Runner.Run("systemctl", "disable", "--now", paths.ServiceName); err != nil {
|
||||||
|
// A unit that was never enabled, or a node where systemd never started it, lands here. It is
|
||||||
|
// not a reason to abort: the remaining steps are what make the node clean.
|
||||||
|
opts.Log(" systemctl disable --now %s: %v (continuing)", paths.ServiceName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. The unit file, then a reload so systemd forgets it.
|
||||||
|
if step(opts, "removing %s", paths.UnitPath) {
|
||||||
|
removeIfPresent(paths.UnitPath, opts)
|
||||||
|
}
|
||||||
|
if step(opts, "reloading systemd") {
|
||||||
|
if err := opts.Runner.Run("systemctl", "daemon-reload"); err != nil {
|
||||||
|
opts.Log(" systemctl daemon-reload: %v (continuing)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. The config: private keys, relay bearer, and the node password when the node is protected.
|
||||||
|
if step(opts, "removing %s (private keys, relay bearer, node password)", paths.ConfigPath) {
|
||||||
|
removeIfPresent(paths.ConfigPath, opts)
|
||||||
|
// The directory holds relay-ca.pem and anything else the operator put beside the config.
|
||||||
|
removeIfPresent(filepath.Dir(paths.ConfigPath), opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Logs.
|
||||||
|
if opts.KeepLogs {
|
||||||
|
step(opts, "keeping %s (--keep-logs)", paths.LogDir)
|
||||||
|
} else if step(opts, "removing %s", paths.LogDir) {
|
||||||
|
removeIfPresent(paths.LogDir, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. The binary, then the user. The user goes last because the steps above ran as that user.
|
||||||
|
if step(opts, "removing %s", paths.BinaryPath) {
|
||||||
|
removeIfPresent(paths.BinaryPath, opts)
|
||||||
|
removeIfPresent(filepath.Dir(paths.BinaryPath), opts)
|
||||||
|
}
|
||||||
|
if step(opts, "removing the %s system user", paths.User) {
|
||||||
|
if err := opts.Runner.Run("userdel", paths.User); err != nil {
|
||||||
|
opts.Log(" userdel %s: %v (continuing)", paths.User, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.DryRun {
|
||||||
|
opts.Log("dry run: nothing was removed")
|
||||||
|
} else {
|
||||||
|
opts.Log("msa-agent removed")
|
||||||
|
// The one thing this command cannot do for the operator, and it cannot by design: the agent holds
|
||||||
|
// no admin token, because a node is not an operator, and a compromised node that could remove its
|
||||||
|
// neighbours would be a worse problem than the one this hint solves.
|
||||||
|
opts.Log("%s", opts.RemovalHint)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// step reports one line of the plan, and says whether the action should be performed.
|
||||||
|
func step(opts Options, format string, args ...any) bool {
|
||||||
|
return stepWith(opts.DryRun, opts.Log, format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepWith is the same decision for the install half, whose options type is different. Taking the two
|
||||||
|
// fields rather than the struct is what keeps "a dry run changes nothing" in one place: two copies of
|
||||||
|
// this would eventually disagree, and the one that disagreed would be the one that ran a command during
|
||||||
|
// a dry run.
|
||||||
|
func stepWith(dryRun bool, log func(string, ...any), format string, args ...any) bool {
|
||||||
|
if dryRun {
|
||||||
|
log("would "+format, args...)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
log(format, args...)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeIfPresent deletes a path, and treats "it is not there" as success.
|
||||||
|
//
|
||||||
|
// A missing path is the normal case on a partially installed node, and reporting it as an error would
|
||||||
|
// train the operator to ignore the errors that matter.
|
||||||
|
func removeIfPresent(path string, opts Options) {
|
||||||
|
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
|
||||||
|
opts.Log(" %s is not present", path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(path); err != nil {
|
||||||
|
opts.Log(" WARNING: could not remove %s: %v", path, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
opts.Log(" removed %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNotRoot is returned when an uninstall is attempted without the privileges the steps need.
|
||||||
|
var ErrNotRoot = errors.New("install: uninstalling needs root (it removes a systemd unit and a system user)")
|
||||||
|
|
||||||
|
// RequireRoot is what the command calls before doing anything.
|
||||||
|
func RequireRoot() error {
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
return ErrNotRoot
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// String renders the plan for a dry run, so an operator can see it without reading this file.
|
||||||
|
func (p Paths) String() string {
|
||||||
|
return fmt.Sprintf("service=%s unit=%s config=%s logs=%s binary=%s user=%s",
|
||||||
|
p.ServiceName, p.UnitPath, p.ConfigPath, p.LogDir, p.BinaryPath, p.User)
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
package install
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recorder stands in for systemctl and userdel, so the sequence can be tested without systemd.
|
||||||
|
type recorder struct {
|
||||||
|
calls []string
|
||||||
|
// failOn makes a named command fail, for the cases where a missing unit or an absent user must not
|
||||||
|
// abort the uninstall.
|
||||||
|
failOn string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recorder) Run(name string, args ...string) error {
|
||||||
|
call := name + " " + strings.Join(args, " ")
|
||||||
|
r.calls = append(r.calls, call)
|
||||||
|
if r.failOn != "" && strings.HasPrefix(call, r.failOn) {
|
||||||
|
return fmt.Errorf("simulated failure")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunCaptured is what the install needs to resolve the service user's ids. The real ExecRunner reads
|
||||||
|
// them from id(1); here they are fixed, because what is being tested is that the install asks for them
|
||||||
|
// and applies them, not that this machine has a particular uid.
|
||||||
|
func (r *recorder) RunCaptured(name string, args ...string) (string, error) {
|
||||||
|
r.calls = append(r.calls, name+" "+strings.Join(args, " "))
|
||||||
|
return "1001", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// installed creates the layout an install leaves behind, so the test removes real files.
|
||||||
|
func installed(t *testing.T) Paths {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
paths := Paths{
|
||||||
|
ServiceName: "msa-agent",
|
||||||
|
UnitPath: filepath.Join(root, "etc/systemd/system/msa-agent.service"),
|
||||||
|
ConfigPath: filepath.Join(root, "etc/msa-agent/config.json"),
|
||||||
|
LogDir: filepath.Join(root, "var/log/msa-agent"),
|
||||||
|
BinaryPath: filepath.Join(root, "opt/msa-agent/msa-agent"),
|
||||||
|
User: "msa-agent",
|
||||||
|
}
|
||||||
|
for _, path := range []string{paths.UnitPath, paths.ConfigPath, filepath.Join(paths.LogDir, "audit.log"), paths.BinaryPath} {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUninstallRemovesEverythingTheInstallPlaced(t *testing.T) {
|
||||||
|
paths := installed(t)
|
||||||
|
runner := &recorder{}
|
||||||
|
var lines []string
|
||||||
|
err := Uninstall(Options{
|
||||||
|
Paths: paths,
|
||||||
|
Runner: runner,
|
||||||
|
Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) },
|
||||||
|
NotifyRelay: func() error {
|
||||||
|
lines = append(lines, "relay notified")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Uninstall: %v", err)
|
||||||
|
}
|
||||||
|
for _, path := range []string{paths.UnitPath, paths.ConfigPath, paths.LogDir, paths.BinaryPath} {
|
||||||
|
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("%s survived the uninstall", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The config directory goes with the config: it holds relay-ca.pem and anything else the operator
|
||||||
|
// put beside it.
|
||||||
|
if _, err := os.Stat(filepath.Dir(paths.ConfigPath)); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatal("the config directory survived; it may still hold pinned CA material")
|
||||||
|
}
|
||||||
|
joined := strings.Join(runner.calls, "\n")
|
||||||
|
for _, want := range []string{"systemctl disable --now msa-agent", "systemctl daemon-reload", "userdel msa-agent"} {
|
||||||
|
if !strings.Contains(joined, want) {
|
||||||
|
t.Fatalf("expected %q among the commands, got:\n%s", want, joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The order is the point: the relay has to be told before the node stops existing, or it keeps routing
|
||||||
|
// to a node that is gone and a phone that believes it is paired points at nothing.
|
||||||
|
func TestUninstallTellsTheRelayBeforeItRemovesAnything(t *testing.T) {
|
||||||
|
paths := installed(t)
|
||||||
|
configStillThere := false
|
||||||
|
err := Uninstall(Options{
|
||||||
|
Paths: paths,
|
||||||
|
Runner: &recorder{},
|
||||||
|
Log: func(string, ...any) {},
|
||||||
|
NotifyRelay: func() error {
|
||||||
|
// At the moment the relay is told, the config must still exist: the notification reads it.
|
||||||
|
_, statErr := os.Stat(paths.ConfigPath)
|
||||||
|
configStillThere = statErr == nil
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Uninstall: %v", err)
|
||||||
|
}
|
||||||
|
if !configStillThere {
|
||||||
|
t.Fatal("the config was removed before the relay was told; the device list could not be read")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A relay that cannot be reached must not stop an uninstall. The operator is removing the node either
|
||||||
|
// way, and the alternative — aborting half way — leaves the machine in a state nobody described.
|
||||||
|
func TestUninstallCompletesWhenTheRelayIsUnreachable(t *testing.T) {
|
||||||
|
paths := installed(t)
|
||||||
|
var lines []string
|
||||||
|
err := Uninstall(Options{
|
||||||
|
Paths: paths,
|
||||||
|
Runner: &recorder{},
|
||||||
|
Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) },
|
||||||
|
NotifyRelay: func() error { return fmt.Errorf("relay unreachable") },
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Uninstall: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(paths.ConfigPath); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatal("the uninstall did not finish")
|
||||||
|
}
|
||||||
|
// And the operator is told what is left to do by hand, because the relay still lists this node.
|
||||||
|
output := strings.Join(lines, "\n")
|
||||||
|
if !strings.Contains(output, "WARNING") || !strings.Contains(output, "agents.json") {
|
||||||
|
t.Fatalf("a failed relay notification was not explained:\n%s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dry run removes nothing, and that includes not stopping the service: an operator asking what an
|
||||||
|
// uninstall would do must not lose the node while they read the answer.
|
||||||
|
func TestDryRunRemovesNothingAndRunsNothing(t *testing.T) {
|
||||||
|
paths := installed(t)
|
||||||
|
runner := &recorder{}
|
||||||
|
var lines []string
|
||||||
|
err := Uninstall(Options{
|
||||||
|
Paths: paths,
|
||||||
|
DryRun: true,
|
||||||
|
Runner: runner,
|
||||||
|
Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) },
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Uninstall: %v", err)
|
||||||
|
}
|
||||||
|
if len(runner.calls) != 0 {
|
||||||
|
t.Fatalf("a dry run ran commands: %v", runner.calls)
|
||||||
|
}
|
||||||
|
for _, path := range []string{paths.UnitPath, paths.ConfigPath, paths.BinaryPath} {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Fatalf("a dry run removed %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(lines, "\n"), "dry run: nothing was removed") {
|
||||||
|
t.Fatal("a dry run did not say so")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeepLogsLeavesTheAuditLog(t *testing.T) {
|
||||||
|
paths := installed(t)
|
||||||
|
audit := filepath.Join(paths.LogDir, "audit.log")
|
||||||
|
err := Uninstall(Options{
|
||||||
|
Paths: paths,
|
||||||
|
KeepLogs: true,
|
||||||
|
Runner: &recorder{},
|
||||||
|
Log: func(string, ...any) {},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Uninstall: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(audit); err != nil {
|
||||||
|
t.Fatalf("--keep-logs removed the audit log: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing unit or an absent user is the normal case on a partially installed node, and it must not
|
||||||
|
// abort: reporting it as an error would train the operator to ignore the errors that matter.
|
||||||
|
func TestMissingPiecesDoNotAbortTheUninstall(t *testing.T) {
|
||||||
|
paths := installed(t)
|
||||||
|
// Remove the unit first, then uninstall.
|
||||||
|
if err := os.Remove(paths.UnitPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runner := &recorder{failOn: "systemctl disable"}
|
||||||
|
err := Uninstall(Options{
|
||||||
|
Paths: paths,
|
||||||
|
Runner: runner,
|
||||||
|
Log: func(string, ...any) {},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a missing unit aborted the uninstall: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(paths.ConfigPath); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatal("the config survived")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The plan names every path, so an operator can check it before running anything.
|
||||||
|
func TestThePlanNamesEveryPath(t *testing.T) {
|
||||||
|
plan := DefaultPaths().String()
|
||||||
|
for _, want := range []string{"/etc/systemd/system/msa-agent.service", "/etc/msa-agent/config.json",
|
||||||
|
"/var/log/msa-agent", "/opt/msa-agent/msa-agent", "msa-agent"} {
|
||||||
|
if !strings.Contains(plan, want) {
|
||||||
|
t.Fatalf("the plan does not mention %s:\n%s", want, plan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The unit is embedded, and the vendored copy is produced by the Makefile. This is the drift guard: an
|
||||||
|
// embedded file edited in place is a file the build overwrites, and the mistake should fail here rather
|
||||||
|
// than ship as a node running a unit nobody reviewed.
|
||||||
|
func TestVendoredUnitMatchesSystemd(t *testing.T) {
|
||||||
|
upstream, err := os.ReadFile(filepath.Join("..", "..", "systemd", "msa-agent.service"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read systemd/msa-agent.service: %v", err)
|
||||||
|
}
|
||||||
|
if string(upstream) != string(UnitFile()) {
|
||||||
|
t.Fatal("internal/install/msa-agent.service differs from systemd/msa-agent.service; run make -C agent vendor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The install has to produce what the unit requires, and the two live in different files. These are the
|
||||||
|
// three requirements the first version of the install command missed, each of which fails on the node:
|
||||||
|
//
|
||||||
|
// 1. the unit names a user, and nothing created it;
|
||||||
|
// 2. `enroll` runs under sudo, so the config is root-owned 0600 and the service cannot read its own keys;
|
||||||
|
// 3. the unit was copied from a path in the repository, which a node does not have.
|
||||||
|
func TestInstallSatisfiesWhatTheUnitRequires(t *testing.T) {
|
||||||
|
unit := string(UnitFile())
|
||||||
|
user := regexp.MustCompile(`(?m)^User=(\S+)$`).FindStringSubmatch(unit)
|
||||||
|
if user == nil {
|
||||||
|
t.Skip("the unit no longer names a user, so there is nothing to reconcile")
|
||||||
|
}
|
||||||
|
serviceUser := user[1]
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
paths := Paths{
|
||||||
|
ServiceName: "msa-agent",
|
||||||
|
UnitPath: filepath.Join(root, "etc/systemd/system/msa-agent.service"),
|
||||||
|
ConfigPath: filepath.Join(root, "etc/msa-agent/config.json"),
|
||||||
|
LogDir: filepath.Join(root, "var/log/msa-agent"),
|
||||||
|
BinaryPath: filepath.Join(root, "opt/msa-agent/msa-agent"),
|
||||||
|
User: serviceUser,
|
||||||
|
}
|
||||||
|
for _, path := range []string{paths.BinaryPath, paths.ConfigPath} {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh node: the service user does not exist yet, so the existence check must fail. The recorder
|
||||||
|
// succeeds at everything by default, which would model a node where the user was already there — the
|
||||||
|
// idempotent path, and not the one this test is about.
|
||||||
|
runner := &recorder{failOn: "getent"}
|
||||||
|
var lines []string
|
||||||
|
err := Install(InstallOptions{
|
||||||
|
Paths: paths,
|
||||||
|
Runner: runner,
|
||||||
|
Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) },
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Install: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. The user is created with the flags a service account needs.
|
||||||
|
calls := strings.Join(runner.calls, "\n")
|
||||||
|
if !strings.Contains(calls, "useradd --system --no-create-home") {
|
||||||
|
t.Fatalf("the service user is never created:\n%s", calls)
|
||||||
|
}
|
||||||
|
// 2. Ownership is moved to that user, so the service can read its own config.
|
||||||
|
if !strings.Contains(strings.Join(lines, "\n"), "giving "+serviceUser+" ownership") {
|
||||||
|
t.Fatalf("the config is left owned by root, so the service cannot read it:\n%s", strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
// 3. The unit is written from the binary, not copied from a repository path.
|
||||||
|
if _, err := os.Stat(paths.UnitPath); err != nil {
|
||||||
|
t.Fatalf("the unit was not written: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(calls, "install -m 0644") {
|
||||||
|
t.Fatal("the unit is copied from a path a node does not have")
|
||||||
|
}
|
||||||
|
// And the service is enabled and started, last.
|
||||||
|
if !strings.Contains(calls, "systemctl enable --now msa-agent") {
|
||||||
|
t.Fatalf("the service is never started:\n%s", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dry run of the install changes nothing either, for the same reason as the uninstall's.
|
||||||
|
func TestInstallDryRunChangesNothing(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
paths := Paths{
|
||||||
|
ServiceName: "msa-agent",
|
||||||
|
UnitPath: filepath.Join(root, "etc/systemd/system/msa-agent.service"),
|
||||||
|
ConfigPath: filepath.Join(root, "etc/msa-agent/config.json"),
|
||||||
|
LogDir: filepath.Join(root, "var/log/msa-agent"),
|
||||||
|
BinaryPath: filepath.Join(root, "opt/msa-agent/msa-agent"),
|
||||||
|
User: "msa-agent",
|
||||||
|
}
|
||||||
|
for _, path := range []string{paths.BinaryPath, paths.ConfigPath} {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runner := &recorder{}
|
||||||
|
if err := Install(InstallOptions{Paths: paths, DryRun: true, Runner: runner, Log: func(string, ...any) {}}); err != nil {
|
||||||
|
t.Fatalf("Install: %v", err)
|
||||||
|
}
|
||||||
|
if len(runner.calls) != 0 {
|
||||||
|
t.Fatalf("a dry run ran commands: %v", runner.calls)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(paths.UnitPath); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatal("a dry run wrote the unit")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(paths.LogDir); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatal("a dry run created the log directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An install with no config is refused before anything is changed, because `enroll` is what mints the
|
||||||
|
// node's keys: a service installed without one starts, fails to load its config, and restarts forever.
|
||||||
|
func TestInstallRefusesWithoutAConfigOrBinary(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
paths := Paths{
|
||||||
|
ServiceName: "msa-agent",
|
||||||
|
UnitPath: filepath.Join(root, "etc/systemd/system/msa-agent.service"),
|
||||||
|
ConfigPath: filepath.Join(root, "etc/msa-agent/config.json"),
|
||||||
|
LogDir: filepath.Join(root, "var/log/msa-agent"),
|
||||||
|
BinaryPath: filepath.Join(root, "opt/msa-agent/msa-agent"),
|
||||||
|
User: "msa-agent",
|
||||||
|
}
|
||||||
|
runner := &recorder{}
|
||||||
|
log := func(string, ...any) {}
|
||||||
|
|
||||||
|
// Nothing exists at all.
|
||||||
|
if err := Install(InstallOptions{Paths: paths, Runner: runner, Log: log}); err == nil {
|
||||||
|
t.Fatal("installed without a binary")
|
||||||
|
}
|
||||||
|
// The binary but no config.
|
||||||
|
if err := os.MkdirAll(filepath.Dir(paths.BinaryPath), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(paths.BinaryPath, []byte("x"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err := Install(InstallOptions{Paths: paths, Runner: runner, Log: log})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "enroll") {
|
||||||
|
t.Fatalf("expected a refusal that names enroll, got %v", err)
|
||||||
|
}
|
||||||
|
if len(runner.calls) != 0 {
|
||||||
|
t.Fatalf("a refused install still ran commands: %v", runner.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dry run is asked before the work is done, so it must not refuse for the very conditions it exists
|
||||||
|
// to reveal. An operator on a node that has neither the binary nor the config — the normal state before
|
||||||
|
// an install — is exactly who runs it.
|
||||||
|
func TestInstallDryRunReportsPreconditionsInsteadOfRefusing(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
paths := Paths{
|
||||||
|
ServiceName: "msa-agent",
|
||||||
|
UnitPath: filepath.Join(root, "etc/systemd/system/msa-agent.service"),
|
||||||
|
ConfigPath: filepath.Join(root, "etc/msa-agent/config.json"),
|
||||||
|
LogDir: filepath.Join(root, "var/log/msa-agent"),
|
||||||
|
BinaryPath: filepath.Join(root, "opt/msa-agent/msa-agent"),
|
||||||
|
User: "msa-agent",
|
||||||
|
}
|
||||||
|
var lines []string
|
||||||
|
err := Install(InstallOptions{
|
||||||
|
Paths: paths,
|
||||||
|
DryRun: true,
|
||||||
|
Runner: &recorder{},
|
||||||
|
Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) },
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a dry run on a fresh node was refused: %v", err)
|
||||||
|
}
|
||||||
|
output := strings.Join(lines, "\n")
|
||||||
|
// Both missing preconditions are named, so the operator learns what the real run needs.
|
||||||
|
if !strings.Contains(output, "msa-agent is not there yet") || !strings.Contains(output, "enroll") {
|
||||||
|
t.Fatalf("the dry run did not name the missing pieces:\n%s", output)
|
||||||
|
}
|
||||||
|
// And the plan is still printed.
|
||||||
|
if !strings.Contains(output, "would writing") {
|
||||||
|
t.Fatalf("the dry run printed no plan:\n%s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hint at the end has to name the right place, because the two records live in different places and
|
||||||
|
// are removed by different people. Telling the operator the wrong one sends them to the wrong place.
|
||||||
|
func TestTheRemovalHintIsTheOneItWasGiven(t *testing.T) {
|
||||||
|
paths := installed(t)
|
||||||
|
var lines []string
|
||||||
|
err := Uninstall(Options{
|
||||||
|
Paths: paths,
|
||||||
|
Runner: &recorder{},
|
||||||
|
Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) },
|
||||||
|
RemovalHint: "the relay still lists this node in its enrollment state; remove it in the app",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Uninstall: %v", err)
|
||||||
|
}
|
||||||
|
output := strings.Join(lines, "\n")
|
||||||
|
if !strings.Contains(output, "remove it in the app") {
|
||||||
|
t.Fatalf("the hint was not printed:\n%s", output)
|
||||||
|
}
|
||||||
|
// And the old hard-coded sentence is gone: it named agents.json for every node, including the
|
||||||
|
// self-enrolled ones that are not there.
|
||||||
|
if strings.Contains(output, "agents.json still lists this node") {
|
||||||
|
t.Fatalf("the uninstall still claims every node is in agents.json:\n%s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
-8
@@ -19,6 +19,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -30,9 +31,26 @@ import (
|
|||||||
// Limits. The attempt ceiling is small on purpose: the pairing secret is a human password, and a
|
// Limits. The attempt ceiling is small on purpose: the pairing secret is a human password, and a
|
||||||
// window where anyone on the LAN can try it a thousand times is a window that leaks the node.
|
// window where anyone on the LAN can try it a thousand times is a window that leaks the node.
|
||||||
const (
|
const (
|
||||||
MaxAttempts = 10
|
MaxAttempts = 10
|
||||||
DefaultWindow = 10 * time.Minute
|
DefaultWindow = 10 * time.Minute
|
||||||
MaxRequestBytes = 8 << 10
|
MaxRequestBytes = 8 << 10
|
||||||
|
// DefaultAdminHost is a literal, not a name and not empty.
|
||||||
|
//
|
||||||
|
// The window grants device trust: whoever reaches /provision with the node password gets a key the
|
||||||
|
// agent will obey. So the bind address decides who can even attempt that, and the default has to be
|
||||||
|
// the narrowest thing that still lets the documented flow work.
|
||||||
|
//
|
||||||
|
// It cannot be 127.0.0.1. Pairing happens over the LAN by design — the phone dials the node's LAN
|
||||||
|
// address, which is what makes a QR code unnecessary — and a listener on loopback is not reachable
|
||||||
|
// from another host. A loopback default would not be a hardening measure; it would be a flow that
|
||||||
|
// never works, and the operator's first experience of this feature would be a refused connection
|
||||||
|
// with no explanation.
|
||||||
|
//
|
||||||
|
// It is a literal rather than a name for a different reason: a name is resolved at bind time, so the
|
||||||
|
// listening address would depend on what DNS said at that moment. That is not a decision the
|
||||||
|
// operator made, and on a node whose resolver is compromised it is a decision someone else made.
|
||||||
|
// Operators who want a narrower or wider bind pass --pair-bind.
|
||||||
|
DefaultAdminHost = "0.0.0.0"
|
||||||
DefaultAdminPort = 3001
|
DefaultAdminPort = 3001
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -162,7 +180,7 @@ func (h *Handler) provision(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !h.authorize(body.Password) {
|
if !h.authorize(body.Password) {
|
||||||
h.log("pair_attempt", map[string]any{"outcome": "bad_secret"})
|
h.log("pair_attempt", map[string]any{"outcome": "bad_secret", "peer": peerOf(r)})
|
||||||
writeError(w, http.StatusUnauthorized, "the shared secret did not match")
|
writeError(w, http.StatusUnauthorized, "the shared secret did not match")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -178,7 +196,7 @@ func (h *Handler) provision(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err := config.Save(h.cfgPath, h.cfg); err != nil {
|
if err := config.Save(h.cfgPath, h.cfg); err != nil {
|
||||||
// The device was not added if the save failed, so a retry is safe and the phone must not
|
// The device was not added if the save failed, so a retry is safe and the phone must not
|
||||||
// be handed a keypair the agent will not honour.
|
// be handed a keypair the agent will not honour.
|
||||||
h.log("pair_attempt", map[string]any{"outcome": "save_failed", "error": err.Error()})
|
h.log("pair_attempt", map[string]any{"outcome": "save_failed", "error": err.Error(), "peer": peerOf(r)})
|
||||||
writeError(w, http.StatusInternalServerError, "the agent could not persist the pairing")
|
writeError(w, http.StatusInternalServerError, "the agent could not persist the pairing")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -186,7 +204,7 @@ func (h *Handler) provision(w http.ResponseWriter, r *http.Request) {
|
|||||||
// second phone means re-running --pair, which is the right amount of friction for a socket
|
// second phone means re-running --pair, which is the right amount of friction for a socket
|
||||||
// that grants device trust.
|
// that grants device trust.
|
||||||
h.closeLocked()
|
h.closeLocked()
|
||||||
h.log("pair_provision", map[string]any{"outcome": "ok", "app": deviceID})
|
h.log("pair_provision", map[string]any{"outcome": "ok", "app": deviceID, "peer": peerOf(r)})
|
||||||
writeJSON(w, provisionResponse{
|
writeJSON(w, provisionResponse{
|
||||||
NodeID: h.nodeID,
|
NodeID: h.nodeID,
|
||||||
AppID: deviceID,
|
AppID: deviceID,
|
||||||
@@ -213,7 +231,7 @@ func (h *Handler) destroy(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !h.authorize(body.Password) {
|
if !h.authorize(body.Password) {
|
||||||
h.log("pair_attempt", map[string]any{"outcome": "bad_secret_destroy"})
|
h.log("pair_attempt", map[string]any{"outcome": "bad_secret_destroy", "peer": peerOf(r)})
|
||||||
writeError(w, http.StatusUnauthorized, "the shared secret did not match")
|
writeError(w, http.StatusUnauthorized, "the shared secret did not match")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -223,7 +241,7 @@ func (h *Handler) destroy(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.closeLocked()
|
h.closeLocked()
|
||||||
h.log("pair_destroy", map[string]any{"outcome": "ok"})
|
h.log("pair_destroy", map[string]any{"outcome": "ok", "peer": peerOf(r)})
|
||||||
writeJSON(w, map[string]any{"destroyed": true})
|
writeJSON(w, map[string]any{"destroyed": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +280,20 @@ func mustPublic(private []byte) []byte {
|
|||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// peerOf reports the caller's address, for the log only.
|
||||||
|
//
|
||||||
|
// It is the one piece of context the attempt ceiling cannot supply: "ten failed pairings" says the
|
||||||
|
// window closed, and "ten failed pairings from 10.0.0.7" says someone is trying. Only the IP is kept,
|
||||||
|
// never the port, because the port is a fresh ephemeral number on every attempt and would make two
|
||||||
|
// attempts from one host look like two hosts.
|
||||||
|
func peerOf(r *http.Request) string {
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, body any) {
|
func writeJSON(w http.ResponseWriter, body any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -280,3 +282,65 @@ func TestServeDeadlineClosesTheWindow(t *testing.T) {
|
|||||||
t.Fatalf("status after the deadline: %d", response.StatusCode)
|
t.Fatalf("status after the deadline: %d", response.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The window's default bind has to satisfy two things at once, and they pull in opposite directions.
|
||||||
|
//
|
||||||
|
// It must be reachable from the phone: pairing happens over the LAN by design, so a loopback default
|
||||||
|
// would be a flow that never works. And it must be a literal rather than a name: a name is resolved at
|
||||||
|
// bind time, so the listening address would depend on what DNS said, which is not a decision the
|
||||||
|
// operator made.
|
||||||
|
func TestTheDefaultBindIsALiteralAndIsReachableFromTheLAN(t *testing.T) {
|
||||||
|
if net.ParseIP(DefaultAdminHost) == nil {
|
||||||
|
t.Fatalf("%q is not an IP literal; a name would be resolved at bind time", DefaultAdminHost)
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(DefaultAdminHost)
|
||||||
|
if ip.IsLoopback() {
|
||||||
|
t.Fatal("a loopback default cannot be reached from the phone, and pairing is a LAN flow")
|
||||||
|
}
|
||||||
|
// Any-address is the default because the documented flow requires it; the operator narrows it with
|
||||||
|
// --pair-bind when the node is routable from somewhere it should not be.
|
||||||
|
if !ip.IsUnspecified() {
|
||||||
|
t.Fatalf("the default bind changed to %q; the documented flow and the deployment notes assume %q",
|
||||||
|
DefaultAdminHost, "0.0.0.0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The log has to name the caller. "Ten failed pairings" tells an operator the window closed; "ten
|
||||||
|
// failed pairings from 10.0.0.7" tells them someone is trying, which is the only actionable form of
|
||||||
|
// the same fact and the one thing the attempt ceiling cannot supply.
|
||||||
|
func TestAPairingAttemptIsLoggedWithItsPeer(t *testing.T) {
|
||||||
|
path, cfg, _ := agentConfig(t)
|
||||||
|
var mu sync.Mutex
|
||||||
|
var attempts []map[string]any
|
||||||
|
h, err := New(path, cfg, func(event string, fields map[string]any) {
|
||||||
|
if event != "pair_attempt" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
attempts = append(attempts, fields)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
signPub, kxPub := phoneKeys(t)
|
||||||
|
|
||||||
|
request := httptest.NewRequest("POST", "/provision", provisionBody(t, "wrong", signPub, kxPub))
|
||||||
|
// A caller behind a NAT, which is the shape a real attempt has.
|
||||||
|
request.RemoteAddr = "10.0.0.7:54321"
|
||||||
|
h.Routes().ServeHTTP(httptest.NewRecorder(), request)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if len(attempts) != 1 {
|
||||||
|
t.Fatalf("expected one logged attempt, got %d", len(attempts))
|
||||||
|
}
|
||||||
|
if attempts[0]["outcome"] != "bad_secret" {
|
||||||
|
t.Fatalf("outcome is %v, want bad_secret", attempts[0]["outcome"])
|
||||||
|
}
|
||||||
|
// The host without its ephemeral port: the port is a fresh number on every attempt, so keeping it
|
||||||
|
// would make two attempts from one host look like two hosts.
|
||||||
|
if attempts[0]["peer"] != "10.0.0.7" {
|
||||||
|
t.Fatalf("peer is %v, want the host alone", attempts[0]["peer"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"dangerous": ["/api/sms/send", "/api/sms/batch-delete"],
|
"dangerous": ["/api/sms/send", "/api/sms/batch-delete"],
|
||||||
"limits": {
|
"limits": {
|
||||||
"dangerousWritesPerMinute": 6,
|
"dangerousWritesPerMinute": 6,
|
||||||
|
"agentUpstreamTimeoutSeconds": 20,
|
||||||
"maxRequestBytes": 262144,
|
"maxRequestBytes": 262144,
|
||||||
"maxResponseBytes": 262144,
|
"maxResponseBytes": 262144,
|
||||||
"requestTtlSeconds": 30,
|
"requestTtlSeconds": 30,
|
||||||
|
|||||||
+32
-14
@@ -39,12 +39,17 @@ type AuthPaths struct {
|
|||||||
|
|
||||||
type Limits struct {
|
type Limits struct {
|
||||||
DangerousWritesPerMinute int `json:"dangerousWritesPerMinute"`
|
DangerousWritesPerMinute int `json:"dangerousWritesPerMinute"`
|
||||||
MaxRequestBytes int `json:"maxRequestBytes"`
|
// AgentUpstreamTimeoutSeconds is how long one loopback call to SimAdmin may take. It lives in the
|
||||||
MaxResponseBytes int `json:"maxResponseBytes"`
|
// policy rather than only in the agent's code because the relay's deadline has to be compared
|
||||||
RequestTTLSeconds int `json:"requestTtlSeconds"`
|
// against it, and a comparison between a policy file and a constant in a binary is not one
|
||||||
ClockSkewSeconds int `json:"clockSkewSeconds"`
|
// anything can check.
|
||||||
ReplayCacheSize int `json:"replayCacheSize"`
|
AgentUpstreamTimeoutSeconds int `json:"agentUpstreamTimeoutSeconds"`
|
||||||
MailboxDepth int `json:"mailboxDepth"`
|
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 {
|
type Policy struct {
|
||||||
@@ -148,13 +153,14 @@ func Parse(raw []byte) (*Policy, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for name, value := range map[string]int{
|
for name, value := range map[string]int{
|
||||||
"dangerousWritesPerMinute": p.Limits.DangerousWritesPerMinute,
|
"dangerousWritesPerMinute": p.Limits.DangerousWritesPerMinute,
|
||||||
"maxRequestBytes": p.Limits.MaxRequestBytes,
|
"maxRequestBytes": p.Limits.MaxRequestBytes,
|
||||||
"maxResponseBytes": p.Limits.MaxResponseBytes,
|
"maxResponseBytes": p.Limits.MaxResponseBytes,
|
||||||
"requestTtlSeconds": p.Limits.RequestTTLSeconds,
|
"requestTtlSeconds": p.Limits.RequestTTLSeconds,
|
||||||
"clockSkewSeconds": p.Limits.ClockSkewSeconds,
|
"agentUpstreamTimeoutSeconds": p.Limits.AgentUpstreamTimeoutSeconds,
|
||||||
"replayCacheSize": p.Limits.ReplayCacheSize,
|
"clockSkewSeconds": p.Limits.ClockSkewSeconds,
|
||||||
"mailboxDepth": p.Limits.MailboxDepth,
|
"replayCacheSize": p.Limits.ReplayCacheSize,
|
||||||
|
"mailboxDepth": p.Limits.MailboxDepth,
|
||||||
} {
|
} {
|
||||||
if value <= 0 {
|
if value <= 0 {
|
||||||
return nil, fmt.Errorf("policy: limits.%s must be positive, got %d", name, value)
|
return nil, fmt.Errorf("policy: limits.%s must be positive, got %d", name, value)
|
||||||
@@ -163,8 +169,20 @@ func Parse(raw []byte) (*Policy, error) {
|
|||||||
if p.Limits.ClockSkewSeconds > 300 {
|
if p.Limits.ClockSkewSeconds > 300 {
|
||||||
return nil, errors.New("policy: clockSkewSeconds is too wide to bound replay")
|
return nil, errors.New("policy: clockSkewSeconds is too wide to bound replay")
|
||||||
}
|
}
|
||||||
|
// The relay's deadline has to sit inside the clock-skew window, and the reason is not tidiness.
|
||||||
|
//
|
||||||
|
// The freshness check above rejects an envelope whose timestamp is further from now than
|
||||||
|
// clockSkewSeconds. The agent's own deadline check rejects one older than requestTtlSeconds. If the
|
||||||
|
// TTL were the larger of the two, the freshness check would fire first and the deadline check would
|
||||||
|
// be unreachable — a rule that can never fire, which is the kind of thing that gets discovered
|
||||||
|
// during an incident by someone who assumed it was working.
|
||||||
if p.Limits.RequestTTLSeconds > p.Limits.ClockSkewSeconds {
|
if p.Limits.RequestTTLSeconds > p.Limits.ClockSkewSeconds {
|
||||||
return nil, errors.New("policy: requestTtlSeconds exceeds clockSkewSeconds and would never be enforced")
|
return nil, errors.New("policy: requestTtlSeconds exceeds clockSkewSeconds, so the agent's deadline check would never be reached")
|
||||||
|
}
|
||||||
|
// And the node must not be told to wait longer than the phone is willing to. The two are one
|
||||||
|
// decision — how long a request may take end to end — stated in the file that both sides read.
|
||||||
|
if p.Limits.RequestTTLSeconds*1000 <= p.Limits.AgentUpstreamTimeoutSeconds*1000 {
|
||||||
|
return nil, errors.New("policy: requestTtlSeconds must exceed the per-request upstream timeout, or the relay deadline is not the longer bound")
|
||||||
}
|
}
|
||||||
if p.Limits.ReplayCacheSize < p.Limits.MailboxDepth {
|
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")
|
return nil, errors.New("policy: replayCacheSize is smaller than mailboxDepth, so entries can be evicted while still queued")
|
||||||
|
|||||||
@@ -11,8 +11,14 @@ import (
|
|||||||
// TestVendoredCopyMatchesContracts fails loudly when the embedded policy and contracts/ drift apart.
|
// 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,
|
// 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.
|
// which is exactly the silent-divergence case embedding was supposed to avoid.
|
||||||
|
//
|
||||||
|
// The file this reads is the one this repository owns. The relay keeps its own copy — it derives its
|
||||||
|
// limits from the same numbers — and the two are compared at runtime by the policy digest the agent
|
||||||
|
// reports in its handshake, which the relay surfaces per node. A build-time comparison across two
|
||||||
|
// repositories is not something either side can make, so the guard is the digest and the drift is
|
||||||
|
// visible in /v1/status rather than in a test.
|
||||||
func TestVendoredCopyMatchesContracts(t *testing.T) {
|
func TestVendoredCopyMatchesContracts(t *testing.T) {
|
||||||
upstream, err := os.ReadFile(filepath.Join("..", "..", "..", "contracts", "agent-policy.json"))
|
upstream, err := os.ReadFile(filepath.Join("..", "..", "contracts", "agent-policy.json"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read contracts/agent-policy.json: %v", err)
|
t.Fatalf("read contracts/agent-policy.json: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ func NullAudit() *Audit { return &Audit{} }
|
|||||||
|
|
||||||
// Write appends one entry. A write failure is returned to the caller rather than panicking: the
|
// 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.
|
// audit log is evidence, not a control, and a full disk must not become a remote denial of service.
|
||||||
|
//
|
||||||
|
// Returning nil after Close is deliberate and is the one case where a dropped entry is not an error:
|
||||||
|
// a shutdown path racing an in-flight request would otherwise log a failure for every request that
|
||||||
|
// happened to arrive at that moment, which is noise rather than information. The executor logs what it
|
||||||
|
// sees either way, so the difference is visible in the agent's log.
|
||||||
func (a *Audit) Write(entry Entry) error {
|
func (a *Audit) Write(entry Entry) error {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
@@ -75,8 +80,12 @@ func (a *Audit) Write(entry Entry) error {
|
|||||||
return a.sink.Sync()
|
return a.sink.Sync()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close releases the file. Subsequent writes are silently dropped, which is the right behaviour for
|
// Close releases the file.
|
||||||
// a shutdown path: failing the request that happens to race with a clean exit buys nothing.
|
//
|
||||||
|
// A write after Close is dropped, and dropped silently is the wrong word for it: the executor reports
|
||||||
|
// every write error it sees, and this returns one, so a request racing a clean shutdown shows up as a
|
||||||
|
// warning rather than as a missing line nobody notices. The alternative — failing the request — buys
|
||||||
|
// nothing, because the command it describes has already run or been refused.
|
||||||
func (a *Audit) Close() error {
|
func (a *Audit) Close() error {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -90,7 +91,10 @@ func NewExecutor(pol *policy.Policy, cfg Config) (*Executor, error) {
|
|||||||
}
|
}
|
||||||
timeout := cfg.Timeout
|
timeout := cfg.Timeout
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
timeout = 20 * time.Second
|
// From the policy, not a constant here. The relay's deadline is compared against this number
|
||||||
|
// when the policy is validated, and a comparison against a constant inside a binary is not one
|
||||||
|
// anything can check.
|
||||||
|
timeout = time.Duration(pol.Limits.AgentUpstreamTimeoutSeconds) * time.Second
|
||||||
}
|
}
|
||||||
client, err := egress.NewClient(target, timeout)
|
client, err := egress.NewClient(target, timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -144,6 +148,21 @@ func (e *Executor) Execute(ctx context.Context, envelope wire.Envelope, inner wi
|
|||||||
if err := Freshness(envelope.TS, uint64(now.Unix()), e.pol.Limits.ClockSkewSeconds); err != nil {
|
if err := Freshness(envelope.TS, uint64(now.Unix()), e.pol.Limits.ClockSkewSeconds); err != nil {
|
||||||
return Result{}, e.refuse(envelope, inner, ClassExpired, err)
|
return Result{}, e.refuse(envelope, inner, ClassExpired, err)
|
||||||
}
|
}
|
||||||
|
// The phone's own deadline, checked before anything is executed.
|
||||||
|
//
|
||||||
|
// The relay answers 504 with "it may or may not have executed it; check the node before retrying",
|
||||||
|
// which is honest — but it is only honest while it stays true. A batch is processed serially, so
|
||||||
|
// the eighth envelope of a full mailbox can begin up to eight upstream timeouts after it was
|
||||||
|
// signed; by then the phone has been told the request failed and the user may already have retried.
|
||||||
|
// Executing it at that point turns an honest "unknown" into a duplicate SMS.
|
||||||
|
//
|
||||||
|
// The relay's deadline is the authority on when that happened, because the relay is the party that
|
||||||
|
// starts the clock. Its value is therefore carried in the policy and asserted to be no larger than
|
||||||
|
// the clock-skew window, which is what makes the freshness check above unable to mask this one.
|
||||||
|
if age := int64(now.Unix()) - int64(envelope.TS); age > int64(e.pol.Limits.RequestTTLSeconds) {
|
||||||
|
return Result{}, e.refuse(envelope, inner, ClassExpired,
|
||||||
|
fmt.Errorf("%w: signed %ds ago, deadline is %ds", ErrExpired, age, e.pol.Limits.RequestTTLSeconds))
|
||||||
|
}
|
||||||
if e.replay.Seen(envelope.Req, now) {
|
if e.replay.Seen(envelope.Req, now) {
|
||||||
return Result{}, e.refuse(envelope, inner, ClassReplay, fmt.Errorf("%w: %s", ErrReplay, envelope.Req))
|
return Result{}, e.refuse(envelope, inner, ClassReplay, fmt.Errorf("%w: %s", ErrReplay, envelope.Req))
|
||||||
}
|
}
|
||||||
@@ -261,11 +280,21 @@ func (e *Executor) refuse(envelope wire.Envelope, inner wire.InnerRequest, class
|
|||||||
return &Refusal{Class: class, Path: inner.Path, Err: err}
|
return &Refusal{Class: class, Path: inner.Path, Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// record appends one audit entry, and reports a failure to the process log.
|
||||||
|
//
|
||||||
|
// The write error is not propagated to the caller: the audit log is evidence, not a control, and a
|
||||||
|
// full disk must not become a remote denial of service — a request that already succeeded would start
|
||||||
|
// failing because the log could not be appended to, which is the wrong thing to break.
|
||||||
|
//
|
||||||
|
// But it must not vanish either. Silently losing audit entries is the failure mode where an operator
|
||||||
|
// believes they have a record and does not, and the whole point of the log is to be able to answer
|
||||||
|
// "who asked for what" afterwards. So the error goes to the agent's own log, where a full disk shows
|
||||||
|
// up as a repeating line rather than as an absence.
|
||||||
func (e *Executor) record(envelope wire.Envelope, method, path string, status int, outcome string) {
|
func (e *Executor) record(envelope wire.Envelope, method, path string, status int, outcome string) {
|
||||||
if e.audit == nil {
|
if e.audit == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
e.audit.Write(Entry{
|
if err := e.audit.Write(Entry{
|
||||||
At: e.clock(),
|
At: e.clock(),
|
||||||
Node: envelope.Node,
|
Node: envelope.Node,
|
||||||
Req: envelope.Req,
|
Req: envelope.Req,
|
||||||
@@ -274,7 +303,9 @@ func (e *Executor) record(envelope wire.Envelope, method, path string, status in
|
|||||||
Path: path,
|
Path: path,
|
||||||
Status: status,
|
Status: status,
|
||||||
Outcome: outcome,
|
Outcome: outcome,
|
||||||
})
|
}); err != nil {
|
||||||
|
slog.Warn("audit entry could not be written", "error", err, "req", envelope.Req, "outcome", outcome)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// rateLimiter is a sliding-window counter over the dangerous writes this node has performed.
|
// rateLimiter is a sliding-window counter over the dangerous writes this node has performed.
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package runtime
|
package runtime
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -544,3 +546,116 @@ func TestExecutorRefusesToStartWithoutAPolicyOrPort(t *testing.T) {
|
|||||||
t.Fatal("port 0 was accepted")
|
t.Fatal("port 0 was accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A failed audit write must be visible somewhere. The entry cannot fail the request — the audit log is
|
||||||
|
// evidence, not a control, and a full disk must not become a remote denial of service — but silently
|
||||||
|
// losing entries is the failure mode where an operator believes they have a record and does not.
|
||||||
|
func TestAFailedAuditWriteIsReported(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
path := filepath.Join(directory, "audit.log")
|
||||||
|
audit, err := OpenAudit(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Replace the sink with one that fails, which is what a full disk looks like from here. Reaching
|
||||||
|
// into the struct is deliberate: the alternative is filling a filesystem in a test.
|
||||||
|
audit.mu.Lock()
|
||||||
|
audit.sink = nil
|
||||||
|
audit.mu.Unlock()
|
||||||
|
|
||||||
|
var captured bytes.Buffer
|
||||||
|
previous := slog.Default()
|
||||||
|
slog.SetDefault(slog.New(slog.NewJSONHandler(&captured, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||||
|
defer slog.SetDefault(previous)
|
||||||
|
|
||||||
|
// Close() drops the sink, so a write after it returns nil rather than an error — that is the
|
||||||
|
// documented shutdown case. Force the error path by giving it a closed file instead.
|
||||||
|
file, err := os.OpenFile(filepath.Join(directory, "closed.log"), os.O_CREATE|os.O_WRONLY, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
audit.mu.Lock()
|
||||||
|
audit.sink = file
|
||||||
|
audit.mu.Unlock()
|
||||||
|
|
||||||
|
if err := audit.Write(Entry{Node: "nd_x", Req: "r1", Outcome: "ok"}); err == nil {
|
||||||
|
t.Fatal("writing to a closed file reported success")
|
||||||
|
}
|
||||||
|
// And the executor's own path reports it rather than swallowing it.
|
||||||
|
exec, err := NewExecutor(testPolicy(t), Config{Port: 3000, Audit: audit})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
exec.record(wire.Envelope{Node: "nd_x", Req: "r2", App: "ap_x"}, "GET", "/api/health", 200, "ok")
|
||||||
|
if !strings.Contains(captured.String(), "audit entry could not be written") {
|
||||||
|
t.Fatalf("a failed audit write was not reported; log was: %s", captured.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request whose phone has already given up must not be executed.
|
||||||
|
//
|
||||||
|
// The relay answers 504 with "it may or may not have executed it; check the node before retrying",
|
||||||
|
// which is honest — but only while it stays true. A batch is processed serially, so the eighth
|
||||||
|
// envelope of a full mailbox can begin up to eight upstream timeouts after it was signed. By then the
|
||||||
|
// phone has been told the request failed and the user may have retried; executing it turns an honest
|
||||||
|
// "unknown" into a duplicate SMS.
|
||||||
|
func TestARequestPastItsDeadlineIsNotExecuted(t *testing.T) {
|
||||||
|
// Counted by path: the agent probes /api/auth/status before its first forward, and counting every
|
||||||
|
// request would make the first assertion pass for the wrong reason.
|
||||||
|
executed := 0
|
||||||
|
f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/sms/send" {
|
||||||
|
executed++
|
||||||
|
}
|
||||||
|
w.WriteHeader(200)
|
||||||
|
}))
|
||||||
|
pol := testPolicy(t)
|
||||||
|
body := []byte(`{"phone_number":"+8613800000000","content":"hello"}`)
|
||||||
|
intent, err := wire.IntentDigest("POST", "/api/sms/send", body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inner := wire.InnerRequest{Method: "POST", Path: "/api/sms/send", Body: body, Intent: intent}
|
||||||
|
|
||||||
|
// One second inside the deadline: executed, because the phone is still waiting.
|
||||||
|
envelope := envelopeAt("nd_test0001", newReqID(1), uint64(f.now.Unix()))
|
||||||
|
if _, refusal := f.executor.Execute(context.Background(), envelope, inner); refusal != nil {
|
||||||
|
t.Fatalf("a request inside its deadline was refused: %v", refusal)
|
||||||
|
}
|
||||||
|
if executed != 1 {
|
||||||
|
t.Fatalf("expected one execution, got %d", executed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One second past it: refused, and nothing is forwarded.
|
||||||
|
f.advance(time.Duration(pol.Limits.RequestTTLSeconds+1) * time.Second)
|
||||||
|
late := envelopeAt("nd_test0001", newReqID(2), uint64(f.now.Unix()-int64(pol.Limits.RequestTTLSeconds)-1))
|
||||||
|
_, refusal := f.executor.Execute(context.Background(), late, inner)
|
||||||
|
if refusal == nil {
|
||||||
|
t.Fatal("a request past its deadline was executed; the phone was told it had failed")
|
||||||
|
}
|
||||||
|
if refusal.Class != ClassExpired {
|
||||||
|
t.Fatalf("refused as %s, want %s", refusal.Class, ClassExpired)
|
||||||
|
}
|
||||||
|
if executed != 1 {
|
||||||
|
t.Fatalf("the late request was forwarded anyway (%d executions)", executed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The deadline check is only reachable because the policy keeps the TTL inside the clock-skew window.
|
||||||
|
// If it were not, the freshness check would fire first and the deadline rule could never be reached —
|
||||||
|
// a rule that never fires is one nobody discovers is broken until an incident.
|
||||||
|
func TestThePolicyKeepsTheDeadlineInsideTheFreshnessWindow(t *testing.T) {
|
||||||
|
pol := testPolicy(t)
|
||||||
|
if pol.Limits.RequestTTLSeconds > pol.Limits.ClockSkewSeconds {
|
||||||
|
t.Fatalf("requestTtlSeconds (%d) exceeds clockSkewSeconds (%d); the deadline check is unreachable",
|
||||||
|
pol.Limits.RequestTTLSeconds, pol.Limits.ClockSkewSeconds)
|
||||||
|
}
|
||||||
|
// And the relay's deadline is the longer bound, or the phone gives up before the node does.
|
||||||
|
if pol.Limits.RequestTTLSeconds <= pol.Limits.AgentUpstreamTimeoutSeconds {
|
||||||
|
t.Fatalf("the relay deadline (%ds) is not longer than one upstream call (%ds)",
|
||||||
|
pol.Limits.RequestTTLSeconds, pol.Limits.AgentUpstreamTimeoutSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,21 @@ const (
|
|||||||
PathDevices = "/v1/agent/devices"
|
PathDevices = "/v1/agent/devices"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DefaultMaxBytes bounds a relay response body.
|
||||||
|
//
|
||||||
|
// It has to admit a full long-poll batch, which is the largest thing the relay can legitimately send:
|
||||||
|
// one envelope per mailbox slot. The previous 1 MiB was chosen as "generous" without being derived
|
||||||
|
// from anything, and it is smaller than what the relay is now allowed to send — a mailbox of eight
|
||||||
|
// envelopes at the policy's ceiling is about 4 MiB, so a full batch would have been refused as
|
||||||
|
// "relay response exceeds 1048576 bytes", and the agent would have retried a poll the relay answered
|
||||||
|
// correctly. That is the same class of defect as the relay's own envelope ceiling: a bound picked by
|
||||||
|
// feel, on the other side of a seam, disagreeing with the party that produces the data.
|
||||||
|
//
|
||||||
|
// The derivation is stated here rather than computed from the policy because the agent reads the
|
||||||
|
// policy for its own limits; the two numbers are pinned against each other by
|
||||||
|
// test/limits.test.js, which is where a change on either side becomes visible.
|
||||||
|
const DefaultMaxBytes int64 = 8 << 20
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// ErrSessionExpired means the relay no longer recognises this session. The caller re-runs the
|
// ErrSessionExpired means the relay no longer recognises this session. The caller re-runs the
|
||||||
// handshake and polls again; it is not an error worth crashing over.
|
// handshake and polls again; it is not an error worth crashing over.
|
||||||
@@ -142,7 +157,7 @@ func New(cfg Config) (*Client, error) {
|
|||||||
}
|
}
|
||||||
maxBytes := cfg.MaxBytes
|
maxBytes := cfg.MaxBytes
|
||||||
if maxBytes <= 0 {
|
if maxBytes <= 0 {
|
||||||
maxBytes = 1 << 20
|
maxBytes = DefaultMaxBytes
|
||||||
}
|
}
|
||||||
client := &http.Client{Timeout: timeout}
|
client := &http.Client{Timeout: timeout}
|
||||||
if cfg.RootCAs != nil {
|
if cfg.RootCAs != nil {
|
||||||
|
|||||||
Executable
+161
@@ -0,0 +1,161 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Publishes this agent's cross-compiled binaries as a Gitea release.
|
||||||
|
#
|
||||||
|
# The release is what makes the install command real: `msa-agent enroll` runs on the node, and the node
|
||||||
|
# has no Go toolchain, so the binary has to come from somewhere. Before this the only way to get one was
|
||||||
|
# to build it on another machine and scp it, which is a step the install command cannot express.
|
||||||
|
#
|
||||||
|
# The token is read from the git credential helper rather than an environment variable, so it is not in
|
||||||
|
# the process table and not in a shell history. Override with MSA_GITEA_TOKEN when running somewhere
|
||||||
|
# that has no helper.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/release.sh # publish the version in the Makefile
|
||||||
|
# ./scripts/release.sh --tag v1.0.0 # publish an explicit tag
|
||||||
|
# ./scripts/release.sh --dry-run # build, checksum, print, upload nothing
|
||||||
|
#
|
||||||
|
# This repository must stay publicly readable: the install command fetches the binary from a node that
|
||||||
|
# has no credentials, and Gitea answers 404 for a private repository, so the failure on the node looks
|
||||||
|
# like a wrong URL. The script checks that and refuses rather than publishing something unreachable.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
REMOTE="$(git config --get remote.origin.url)"
|
||||||
|
HOST="$(printf '%s' "$REMOTE" | sed -E 's#^https?://([^/]+)/.*#\1#')"
|
||||||
|
SLUG="$(printf '%s' "$REMOTE" | sed -E 's#^https?://[^/]+/##; s#\.git$##')"
|
||||||
|
if [[ -z "$HOST" || -z "$SLUG" ]]; then
|
||||||
|
echo "release: cannot parse the origin remote ($REMOTE)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="$(sed -n 's/^VERSION[[:space:]]*?*=[[:space:]]*//p' Makefile | head -1)"
|
||||||
|
TAG="v${VERSION}"
|
||||||
|
DRY_RUN=0
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--tag) TAG="$2"; shift 2 ;;
|
||||||
|
--dry-run) DRY_RUN=1; shift ;;
|
||||||
|
*) echo "release: unknown argument $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
step() { printf '\n\033[1m== %s\033[0m\n' "$1"; }
|
||||||
|
|
||||||
|
step "building ${TAG}"
|
||||||
|
# VERSION is passed through so the binary reports the tag it was published under, which is what an
|
||||||
|
# operator sees in `msa-agent version` and in the agent's startup log line.
|
||||||
|
make build-arm64 build-armv7 VERSION="${VERSION}"
|
||||||
|
|
||||||
|
step "checksums"
|
||||||
|
(
|
||||||
|
cd dist
|
||||||
|
shasum -a 256 msa-agent-linux-arm64 msa-agent-linux-armv7 > SHA256SUMS
|
||||||
|
cat SHA256SUMS
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||||
|
step "dry run: nothing was uploaded"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
TOKEN="${MSA_GITEA_TOKEN:-}"
|
||||||
|
if [[ -z "$TOKEN" ]]; then
|
||||||
|
# The credential helper is what `git push` already uses, so there is no second secret to keep.
|
||||||
|
TOKEN="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill 2>/dev/null | sed -n 's/^password=//p')"
|
||||||
|
fi
|
||||||
|
if [[ -z "$TOKEN" ]]; then
|
||||||
|
echo "release: no API token. Set MSA_GITEA_TOKEN, or make sure git can authenticate to $HOST." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
API="https://${HOST}/api/v1/repos/${SLUG}"
|
||||||
|
AUTH=(-H "Authorization: token ${TOKEN}")
|
||||||
|
|
||||||
|
step "target repository"
|
||||||
|
PRIVATE="$(curl -sf "${AUTH[@]}" "${API}" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("private"))' 2>/dev/null || echo unknown)"
|
||||||
|
echo "${SLUG} (private: ${PRIVATE})"
|
||||||
|
if [[ "$PRIVATE" != "False" ]]; then
|
||||||
|
echo "release: ${SLUG} is not publicly readable. A node fetching the binary has no credentials and" >&2
|
||||||
|
echo " would get 404, which looks like a wrong URL. Make it public before publishing." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "creating the release ${TAG}"
|
||||||
|
# A release that already exists is reused rather than refused: re-running this after a fix should
|
||||||
|
# replace the binaries, and failing here would mean the operator has to delete the release by hand.
|
||||||
|
EXISTING="$(curl -sf "${AUTH[@]}" "${API}/releases/tags/${TAG}" 2>/dev/null || true)"
|
||||||
|
if [[ -n "$EXISTING" ]]; then
|
||||||
|
RELEASE_ID="$(printf '%s' "$EXISTING" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
|
||||||
|
echo "reusing release ${TAG} (id ${RELEASE_ID})"
|
||||||
|
else
|
||||||
|
BODY="$(python3 - "$TAG" "$VERSION" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
tag, version = sys.argv[1], sys.argv[2]
|
||||||
|
print(json.dumps({
|
||||||
|
"tag_name": tag,
|
||||||
|
"name": f"simadmin-relay agent {version}",
|
||||||
|
"body": (
|
||||||
|
"Outbound-only node agent for SimAdmin nodes, cross-compiled for the ARM Debian boxes SimAdmin "
|
||||||
|
"runs on. Static, no runtime dependencies, no third-party modules.\n\n"
|
||||||
|
"Install on a node:\n\n"
|
||||||
|
"```sh\n"
|
||||||
|
f"curl -fsSL https://HOST/SLUG/releases/download/{tag}/msa-agent-linux-arm64 \\\n"
|
||||||
|
" -o /opt/msa-agent/msa-agent && chmod 755 /opt/msa-agent/msa-agent\n"
|
||||||
|
"/opt/msa-agent/msa-agent enroll --relay https://your-relay --code <code> \\\n"
|
||||||
|
" --write /etc/msa-agent/config.json\n"
|
||||||
|
"/opt/msa-agent/msa-agent install\n"
|
||||||
|
"```\n\n"
|
||||||
|
"`armv7` is the same build for 32-bit ARM. `SHA256SUMS` covers both.\n\n"
|
||||||
|
"Removing it again is `msa-agent uninstall` on the node; `--dry-run` shows the plan first.\n"
|
||||||
|
),
|
||||||
|
"draft": False,
|
||||||
|
"prerelease": False,
|
||||||
|
}, ensure_ascii=False))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
BODY="${BODY//HOST/$HOST}"
|
||||||
|
BODY="${BODY//SLUG/$SLUG}"
|
||||||
|
RELEASE_ID="$(curl -sf -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \
|
||||||
|
"${API}/releases" -d "$BODY" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
|
||||||
|
echo "created release ${TAG} (id ${RELEASE_ID})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "uploading binaries"
|
||||||
|
for asset in msa-agent-linux-arm64 msa-agent-linux-armv7 SHA256SUMS; do
|
||||||
|
# An asset with the same name cannot be replaced, so an existing one is deleted first: otherwise a
|
||||||
|
# re-run after a rebuild would silently leave the previous binary in place.
|
||||||
|
EXISTING_ASSET="$(curl -sf "${AUTH[@]}" "${API}/releases/${RELEASE_ID}/assets" \
|
||||||
|
| python3 -c "
|
||||||
|
import json, sys
|
||||||
|
name = sys.argv[1]
|
||||||
|
for asset in json.load(sys.stdin):
|
||||||
|
if asset['name'] == name:
|
||||||
|
print(asset['id'])
|
||||||
|
break
|
||||||
|
" "$asset" || true)"
|
||||||
|
if [[ -n "$EXISTING_ASSET" ]]; then
|
||||||
|
# Gitea's delete path carries the release id; GitHub's does not. Using GitHub's shape here returns
|
||||||
|
# 404 and, under `set -e`, killed the script silently after the first asset — which is how a
|
||||||
|
# re-publish left the old binary in place while looking like it had replaced it.
|
||||||
|
curl -sf -X DELETE "${AUTH[@]}" "${API}/releases/${RELEASE_ID}/assets/${EXISTING_ASSET}" >/dev/null
|
||||||
|
echo " replaced ${asset}"
|
||||||
|
fi
|
||||||
|
curl -sf -X POST "${AUTH[@]}" \
|
||||||
|
-F "attachment=@dist/${asset}" \
|
||||||
|
"${API}/releases/${RELEASE_ID}/assets?name=${asset}" >/dev/null
|
||||||
|
echo " uploaded ${asset}"
|
||||||
|
done
|
||||||
|
|
||||||
|
step "published"
|
||||||
|
printf 'release: https://%s/%s/releases/tag/%s\n' "$HOST" "$SLUG" "$TAG"
|
||||||
|
printf 'download: https://%s/%s/releases/download/%s/msa-agent-linux-arm64\n' "$HOST" "$SLUG" "$TAG"
|
||||||
|
printf '\n'
|
||||||
|
# The app renders the install command, so the URL it carries has to be this one. Printing it in the
|
||||||
|
# exact form DefaultContainer holds is what makes the two comparable by eye.
|
||||||
|
printf 'DefaultContainer.agentDownload must be:\n'
|
||||||
|
printf ' https://%s/%s/releases/download/%s/msa-agent-linux-arm64\n' "$HOST" "$SLUG" "$TAG"
|
||||||
|
printf '\nVerify it is fetchable with no credentials:\n'
|
||||||
|
printf ' curl -fsSL -o /dev/null -w "%%{http_code}\\n" %s\n' \
|
||||||
|
"https://${HOST}/${SLUG}/releases/download/${TAG}/msa-agent-linux-arm64"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=MultiSimAdmin relay agent
|
Description=MultiSimAdmin relay agent
|
||||||
Documentation=https://gitea.chickliu.fun/Hermes/simadmin-relay
|
Documentation=https://gitea.chickliu.fun/Hermes/msa-agent
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user