Compare commits
3
Commits
3473816167
..
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f499d02ca0 | ||
|
|
9949cfe5b0 | ||
|
|
fc7a77c500 |
@@ -13,7 +13,7 @@ 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
|
||||
VERSION ?= 1.0.1
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||
|
||||
.PHONY: all vendor test vet fmt vectors build build-arm64 build-armv7 clean
|
||||
|
||||
@@ -39,6 +39,20 @@ 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.
|
||||
|
||||
### After installing, pair
|
||||
|
||||
The daemon refuses to start with no paired devices, on purpose: an agent that could authorise nothing
|
||||
must not sit there looking healthy. Pairing happens after install, so a freshly installed node's service
|
||||
will fail and stop — the unit's start limit makes systemd give up rather than restart every five seconds
|
||||
forever, and `systemctl status msa-agent` shows it as failed rather than flapping.
|
||||
|
||||
```sh
|
||||
sudo /opt/msa-agent/msa-agent --pair # opens the LAN window; then pair from the app
|
||||
```
|
||||
|
||||
Then `systemctl start msa-agent` (or just wait — it is enabled) and check `journalctl -u msa-agent -n 20`
|
||||
for the policy digest and the device count.
|
||||
|
||||
## Subcommands
|
||||
|
||||
| Command | What it does |
|
||||
@@ -49,6 +63,7 @@ keeps the audit trail.
|
||||
| `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 |
|
||||
| `--pair-bind` | The address that window binds to (an IP literal; the default is all interfaces) |
|
||||
| `--check` | Validates the config and policy, then exits |
|
||||
| `--once` | Runs a single poll cycle and exits |
|
||||
|
||||
|
||||
@@ -11,6 +11,17 @@ 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
|
||||
# A freshly installed node has no paired devices, and the daemon refuses to run in that state on
|
||||
# purpose: an agent that could authorise nothing must not sit there looking healthy. But pairing happens
|
||||
# *after* install — the operator runs `--pair` on the node and then pairs the phone — so between those
|
||||
# two moments this unit would restart every five seconds, forever, filling the journal with the same
|
||||
# line. Measured on a real kernel: exit 1, "devices is empty, so no phone could ever be authorised".
|
||||
#
|
||||
# StartLimitIntervalSec/StartLimitBurst make systemd give up instead: ten attempts inside five minutes
|
||||
# and the unit enters `failed`, which is a state an operator can see and act on. The install command
|
||||
# prints the next step precisely so that state is expected rather than surprising.
|
||||
StartLimitIntervalSec=300
|
||||
StartLimitBurst=10
|
||||
TimeoutStopSec=20
|
||||
KillSignal=SIGTERM
|
||||
|
||||
|
||||
@@ -141,9 +141,13 @@ func Install(opts InstallOptions) error {
|
||||
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")
|
||||
// The service will fail until a phone is paired, and that is expected rather than a fault: the daemon
|
||||
// refuses an empty device list so it cannot sit there looking healthy while authorising nothing. The
|
||||
// unit's start limit turns that into a `failed` state instead of a restart loop, and this line is
|
||||
// what makes the state expected.
|
||||
opts.Log("msa-agent installed and enabled")
|
||||
opts.Log("next: run `msa-agent --pair` once and pair the phone; until then the service exits and stops")
|
||||
opts.Log("check it with: systemctl status %s; journalctl -u %s -n 20", paths.ServiceName, paths.ServiceName)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -426,3 +426,51 @@ func TestTheRemovalHintIsTheOneItWasGiven(t *testing.T) {
|
||||
t.Fatalf("the uninstall still claims every node is in agents.json:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// The install ends by starting a service that cannot run yet, and the unit has to say what to do about
|
||||
// that. A freshly installed node has no paired devices — pairing happens after install — and the daemon
|
||||
// refuses to run in that state on purpose, because an agent that could authorise nothing must not look
|
||||
// healthy. With `Restart=always` and `RestartSec=5` and no start limit, that is a restart every five
|
||||
// seconds until a phone pairs.
|
||||
//
|
||||
// Found by rehearsing the install on a real kernel: exit 1, "devices is empty, so no phone could ever be
|
||||
// authorised", and the journal filling with the same line.
|
||||
func TestTheUnitGivesUpInsteadOfRestartingForever(t *testing.T) {
|
||||
unit := string(UnitFile())
|
||||
if !strings.Contains(unit, "Restart=always") {
|
||||
t.Skip("the unit no longer restarts, so there is nothing to bound")
|
||||
}
|
||||
if !strings.Contains(unit, "StartLimitIntervalSec=") || !strings.Contains(unit, "StartLimitBurst=") {
|
||||
t.Fatal("the unit restarts without a start limit: an unpaired node would restart every RestartSec forever")
|
||||
}
|
||||
// And the install tells the operator what to do next, so the failed state is expected rather than
|
||||
// surprising.
|
||||
var lines []string
|
||||
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)
|
||||
}
|
||||
}
|
||||
if err := Install(InstallOptions{
|
||||
Paths: paths,
|
||||
Runner: &recorder{failOn: "getent"},
|
||||
Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) },
|
||||
}); err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(lines, "\n"), "--pair") {
|
||||
t.Fatalf("the install does not say to pair next:\n%s", strings.Join(lines, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
@@ -11,6 +11,17 @@ 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
|
||||
# A freshly installed node has no paired devices, and the daemon refuses to run in that state on
|
||||
# purpose: an agent that could authorise nothing must not sit there looking healthy. But pairing happens
|
||||
# *after* install — the operator runs `--pair` on the node and then pairs the phone — so between those
|
||||
# two moments this unit would restart every five seconds, forever, filling the journal with the same
|
||||
# line. Measured on a real kernel: exit 1, "devices is empty, so no phone could ever be authorised".
|
||||
#
|
||||
# StartLimitIntervalSec/StartLimitBurst make systemd give up instead: ten attempts inside five minutes
|
||||
# and the unit enters `failed`, which is a state an operator can see and act on. The install command
|
||||
# prints the next step precisely so that state is expected rather than surprising.
|
||||
StartLimitIntervalSec=300
|
||||
StartLimitBurst=10
|
||||
TimeoutStopSec=20
|
||||
KillSignal=SIGTERM
|
||||
|
||||
|
||||
Reference in New Issue
Block a user