fix: agents enrolled by hand run as systemd services
ci / Protobuf (push) Successful in 12s
ci / Web (lint, test, audit, build) (push) Successful in 2m44s
ci / Shell and licence headers (push) Successful in 21s
ci / Go (lint, test, vuln) (push) Successful in 6m22s
ci / Release build (dry run) (push) Successful in 3m51s
ci / Integration (real BIND and Kea) (push) Successful in 2m33s
ci / Development environment (compose) (push) Successful in 17s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 4m14s
install / Installation (debian:12) (push) Successful in 4m54s
install / Installation (debian:13) (push) Successful in 5m6s
install / Installation (ubuntu:22.04) (push) Successful in 5m16s
install / Installation (rocky:9) (push) Successful in 7m28s
install / Installation (ubuntu:24.04) (push) Successful in 8m58s
release / release (push) Successful in 4m11s
ci / Protobuf (push) Successful in 12s
ci / Web (lint, test, audit, build) (push) Successful in 2m44s
ci / Shell and licence headers (push) Successful in 21s
ci / Go (lint, test, vuln) (push) Successful in 6m22s
ci / Release build (dry run) (push) Successful in 3m51s
ci / Integration (real BIND and Kea) (push) Successful in 2m33s
ci / Development environment (compose) (push) Successful in 17s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 4m14s
install / Installation (debian:12) (push) Successful in 4m54s
install / Installation (debian:13) (push) Successful in 5m6s
install / Installation (ubuntu:22.04) (push) Successful in 5m16s
install / Installation (rocky:9) (push) Successful in 7m28s
install / Installation (ubuntu:24.04) (push) Successful in 8m58s
release / release (push) Successful in 4m11s
An agent installed without token and enrolled with the command of the web interface never ran as a service: the installer left the unit disabled and the root-run enrollment left an identity the service user could not read. The installer now always enables the agent unit (it waits for the identity via ConditionPathExists), enroll as root hands the state over to the service user and enables and starts the unit, and rerunning or upgrading the installer repairs agents enrolled by hand. The web interface shows the matching installer command next to the enroll command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
346d24cb34
commit
2555028956
+44
-1
@@ -14,8 +14,10 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -79,6 +81,8 @@ type cli struct {
|
||||
factory RoleFactory
|
||||
stdout io.Writer
|
||||
stderr io.Writer
|
||||
// systemctl runs systemctl (tests replace it).
|
||||
systemctl func(args ...string) ([]byte, error)
|
||||
}
|
||||
|
||||
func (c *cli) flags(name string) (*flag.FlagSet, *string) {
|
||||
@@ -116,6 +120,7 @@ func (c *cli) enroll(args []string) int {
|
||||
fs.StringVar(&opts.CAHash, "ca-hash", "", "fingerprint of the agent CA (sha256:<hex>)")
|
||||
fs.StringVar(&opts.ServerName, "server-name", "", "TLS server name (default: host of --server)")
|
||||
fs.BoolVar(&opts.Force, "force", false, "replace an existing identity")
|
||||
noStart := fs.Bool("no-start", false, "do not enable and start the systemd service afterwards")
|
||||
if code := parse(fs, args); code >= 0 {
|
||||
return code
|
||||
}
|
||||
@@ -127,10 +132,11 @@ func (c *cli) enroll(args []string) int {
|
||||
if err != nil {
|
||||
return c.fail(err)
|
||||
}
|
||||
store, unlock, err := openLocked(cfg)
|
||||
store, release, err := openLocked(cfg)
|
||||
if err != nil {
|
||||
return c.fail(err)
|
||||
}
|
||||
unlock := sync.OnceFunc(release) // released early before starting the service
|
||||
defer unlock()
|
||||
opts.Kind = c.kind
|
||||
opts.Hostname, _ = os.Hostname()
|
||||
@@ -142,9 +148,46 @@ func (c *cli) enroll(args []string) int {
|
||||
}
|
||||
_, _ = fmt.Fprintf(c.stdout, "Enrolled as agent %s (%s) with %s.\nIdentity stored in %s.\n",
|
||||
id.AgentID, c.kind, id.Server, store.Dir())
|
||||
if uid, err := handOverStateDir(store.Dir()); err != nil {
|
||||
_, _ = fmt.Fprintf(c.stderr, "warning: could not hand %s over to its owner: %v\n", store.Dir(), err)
|
||||
} else if uid >= 0 {
|
||||
_, _ = fmt.Fprintf(c.stdout, "Files in %s now belong to the owner of the directory (UID %d).\n", store.Dir(), uid)
|
||||
}
|
||||
// The service takes the state lock itself.
|
||||
unlock()
|
||||
c.startService(*noStart)
|
||||
return 0
|
||||
}
|
||||
|
||||
// startService enables and starts the agent's systemd service after an
|
||||
// enrollment as root on a systemd host, so that a manual enrollment ends
|
||||
// with a running agent (the installer leaves the unit waiting for the
|
||||
// identity). Otherwise, or with --no-start, it says what to run.
|
||||
func (c *cli) startService(noStart bool) {
|
||||
unit := c.binary + ".service"
|
||||
hint := func() {
|
||||
_, _ = fmt.Fprintf(c.stdout, "Start the agent with: sudo systemctl enable --now %s\n", unit)
|
||||
}
|
||||
if noStart || os.Geteuid() != 0 || !systemdRunning() {
|
||||
hint()
|
||||
return
|
||||
}
|
||||
run := c.systemctl
|
||||
if run == nil {
|
||||
run = func(args ...string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
return exec.CommandContext(ctx, "systemctl", args...).CombinedOutput() //nolint:gosec // fixed program; arguments are "enable --now <own unit>"
|
||||
}
|
||||
}
|
||||
if out, err := run("enable", "--now", unit); err != nil {
|
||||
_, _ = fmt.Fprintf(c.stderr, "warning: systemctl enable --now %s failed: %v\n%s", unit, err, out)
|
||||
hint()
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(c.stdout, "Service %s enabled and started.\n", unit)
|
||||
}
|
||||
|
||||
func openLocked(cfg *Config) (*Store, func(), error) {
|
||||
store, err := OpenStore(cfg.StateDir)
|
||||
if err != nil {
|
||||
|
||||
@@ -191,3 +191,16 @@ func TestMainConfigErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartServiceHint: with --no-start (or without root/systemd) enroll
|
||||
// only says how to start the service and never calls systemctl.
|
||||
func TestStartServiceHint(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
called := false
|
||||
c := &cli{binary: "ddi-agent-dns", stdout: &out, stderr: &out,
|
||||
systemctl: func(...string) ([]byte, error) { called = true; return nil, nil }}
|
||||
c.startService(true)
|
||||
if called || !strings.Contains(out.String(), "systemctl enable --now ddi-agent-dns.service") {
|
||||
t.Errorf("called=%v output %q", called, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//go:build !(linux || darwin || freebsd || netbsd || openbsd || dragonfly)
|
||||
|
||||
package core
|
||||
|
||||
// handOverStateDir is a no-op where file ownership works differently; the
|
||||
// agents only run on Linux.
|
||||
func handOverStateDir(string) (int, error) { return -1, nil }
|
||||
|
||||
// systemdRunning is false outside Unix.
|
||||
func systemdRunning() bool { return false }
|
||||
@@ -0,0 +1,51 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//go:build linux || darwin || freebsd || netbsd || openbsd || dragonfly
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// handOverStateDir gives everything below the state directory to the owner
|
||||
// of the directory. The installer creates the directory for the service
|
||||
// user of the agent (e.g. bind); an enrollment run as root would otherwise
|
||||
// leave files the service cannot read. The walk is confined to the
|
||||
// directory (os.Root), so a symlink placed inside cannot redirect the
|
||||
// ownership change. It returns the new owner's UID, or -1 if nothing was
|
||||
// to do (not root, or the directory belongs to root).
|
||||
func handOverStateDir(dir string) (int, error) {
|
||||
if os.Geteuid() != 0 {
|
||||
return -1, nil
|
||||
}
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
st, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok || st.Uid == 0 {
|
||||
return -1, nil
|
||||
}
|
||||
uid, gid := int(st.Uid), int(st.Gid)
|
||||
root, err := os.OpenRoot(dir)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
defer root.Close() //nolint:errcheck // read-only handle
|
||||
err = fs.WalkDir(root.FS(), ".", func(path string, _ fs.DirEntry, err error) error {
|
||||
if err != nil || path == "." {
|
||||
return err
|
||||
}
|
||||
return root.Lchown(path, uid, gid)
|
||||
})
|
||||
return uid, err
|
||||
}
|
||||
|
||||
// systemdRunning reports whether systemd manages this host.
|
||||
func systemdRunning() bool {
|
||||
_, err := os.Stat("/run/systemd/system")
|
||||
return err == nil
|
||||
}
|
||||
@@ -9,6 +9,9 @@ Description=@DISPLAY_NAME@ dhcp agent (Kea DHCPv4)
|
||||
Documentation=https://git.beugsiud.de/beug.k/DDI
|
||||
Wants=network-online.target
|
||||
After=network-online.target isc-kea-dhcp4-server.service kea-dhcp4.service kea-dhcp4-server.service
|
||||
# Enabled by the installer right away; starts once the agent is enrolled
|
||||
# (`enroll` as root starts it directly).
|
||||
ConditionPathExists=/var/lib/@PRODUCT@/agent-dhcp/identity/agent.json
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
@@ -9,6 +9,9 @@ Description=@DISPLAY_NAME@ dns agent (BIND (named))
|
||||
Documentation=https://git.beugsiud.de/beug.k/DDI
|
||||
Wants=network-online.target
|
||||
After=network-online.target named.service bind9.service
|
||||
# Enabled by the installer right away; starts once the agent is enrolled
|
||||
# (`enroll` as root starts it directly).
|
||||
ConditionPathExists=/var/lib/@PRODUCT@/agent-dns/identity/agent.json
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
+5
-2
@@ -54,14 +54,16 @@ Ohne `--version` installiert das Skript das neueste Release (`/api/v1/repos/beug
|
||||
|
||||
Am Ende stehen die URL der Oberfläche und ein **einmaliges Administrator-Passwort** (Benutzer `admin`). Es steht nur auf dem Bildschirm, nicht im Log, und muss bei der ersten Anmeldung geändert werden. Interaktiv fragt der Installer nach einem Lizenzschlüssel (optional, Eingabe auch später in der Oberfläche möglich).
|
||||
|
||||
2. **Agent registrieren:** In der Oberfläche unter *Agents* ein Enrollment-Token für die Rolle anlegen. Die Oberfläche zeigt einen Befehl der Form `ddi-agent-dns enroll --server <host>:8444 --token <token> --ca-hash sha256:<hex>`. Dieselben drei Werte bekommt der Installer auf dem Agent-Host:
|
||||
2. **Agent registrieren:** In der Oberfläche unter *Agents* ein Enrollment-Token für die Rolle anlegen. Die Oberfläche zeigt zwei gleichwertige Befehle: den Installer-Aufruf für einen neuen Host und `ddi-agent-dns enroll --server <host>:8444 --token <token> --ca-hash sha256:<hex>` für einen Host, auf dem der Agent schon installiert ist. Auf einem neuen Host:
|
||||
|
||||
```sh
|
||||
sudo ./install.sh --role agent-dns --server ddi.example.org:8444 \
|
||||
--token <token> --ca-hash sha256:<hex>
|
||||
```
|
||||
|
||||
Für den DHCP-Agent entsprechend `--role agent-dhcp` mit einem Token der Rolle `dhcp`. Ohne Token wird der Agent installiert, aber nicht gestartet; die Registrierung lässt sich durch einen erneuten Aufruf mit `--server --token --ca-hash` nachholen. Pro Aufruf wird genau ein Agent registriert.
|
||||
Für den DHCP-Agent entsprechend `--role agent-dhcp` mit einem Token der Rolle `dhcp`. Pro Aufruf wird genau ein Agent registriert.
|
||||
|
||||
**Ohne Token** wird der Agent installiert und sein Dienst eingeschaltet; systemd startet ihn erst, wenn eine Identität vorhanden ist (`ConditionPathExists`). Registrieren lässt er sich danach auf zwei Wegen: den Installer erneut mit `--server --token --ca-hash` aufrufen oder den `enroll`-Befehl aus der Oberfläche **als root** ausführen. Dieser übergibt die Identität an den Dienstbenutzer (`bind` bzw. `_kea`) und startet den Dienst (`systemctl enable --now`; mit `--no-start` nicht). Ein Agent, der mit einer älteren Version von Hand registriert wurde und nicht läuft, wird durch `install.sh --upgrade` repariert.
|
||||
|
||||
3. **Prüfen:** In der Oberfläche erscheinen die Agents als verbunden; nach dem ersten Ausrollen liefern BIND und Kea die Konfiguration.
|
||||
|
||||
@@ -76,6 +78,7 @@ Alle drei Rollen auf einem Host: `sudo ./install.sh --role server,agent-dns,agen
|
||||
| `--from-dir <verzeichnis>` | Release-Artefakte (Tarballs, `SHA256SUMS`, ggf. `SHA256SUMS.sig`) aus einem Verzeichnis statt Download (offline, Tests) |
|
||||
| `--git-token <token>` | Zugriffstoken, falls Releases eine Anmeldung brauchen (alternativ `DDI_GIT_TOKEN`); wird nie geloggt |
|
||||
| `--server <host:port>`, `--token <token>`, `--ca-hash sha256:<hex>` | Registrierung eines Agents (Werte aus dem Enrollment-Befehl der Oberfläche) |
|
||||
| `--server-name <name>` | TLS-Name des Servers, falls er vom Host in `--server` abweicht (steht dann im Befehl der Oberfläche) |
|
||||
| `--kea-source isc\|distro` | Kea aus dem ISC-Repository (Standard, Kea 3.x mit HA) oder aus der Distribution (ohne HA, siehe unten) |
|
||||
| `--public-url <url>` | URL der Oberfläche, Standard `https://<fqdn>:8443`; ihr Hostname ist auch die Agent-API-Adresse (`<host>:8444`) |
|
||||
| `--tls-cert <datei>`, `--tls-key <datei>` | eigenes Zertifikat (PEM, mit Kette) und Schlüssel; ohne erzeugt der Installer ein selbstsigniertes Zertifikat |
|
||||
|
||||
@@ -90,3 +90,4 @@ Brief §10 verlangt `scripts/install.sh` für die Rollen `server`, `agent-dns` u
|
||||
- `DDI_RELEASE_BASE_URL` darf `<tag>` enthalten; ohne Platzhalter gilt sie als Verzeichnis der Artefakte. `--uninstall --purge` ohne installierte Rollen räumt alle Rollen ab.
|
||||
- Die systemd-Unit des DHCP-Agents erlaubt `AF_NETLINK`: `kea-dhcp4 -t` erkennt die Schnittstellen per Netlink.
|
||||
- Nicht in der CI geprüft: `--kea-source distro` (die Hooks werden ohne Pfad referenziert, was Kea 2.x nicht immer auflöst) sowie AppArmor und SELinux im erzwingenden Modus (Container setzen sie nicht durch).
|
||||
- **Registrierung von Hand (Rückmeldung 2026-10-17):** Ein Agent, der ohne Token installiert und dann mit dem `enroll`-Befehl der Oberfläche registriert wurde, lief nicht als Dienst (Unit nie eingeschaltet, Identität gehörte root). Seitdem schaltet der Installer die Agent-Unit immer ein; `ConditionPathExists` auf `identity/agent.json` lässt systemd sie bis zur Registrierung überspringen. `enroll` als root übergibt das Zustandsverzeichnis an dessen Besitzer (den Dienstbenutzer) und ruft `systemctl enable --now` auf (`--no-start` verhindert das; der Installer nutzt es, weil er selbst startet). Erneuter Lauf und `--upgrade` reparieren die Besitzer und schalten einen registrierten Agent ein. Die Oberfläche zeigt neben dem `enroll`-Befehl den passenden Installer-Aufruf (`install_command`); der Installer kennt dafür `--server-name`.
|
||||
|
||||
+62
-5
@@ -87,6 +87,7 @@ VERSION=""
|
||||
SERVER_ADDR=""
|
||||
ENROLL_TOKEN=""
|
||||
CA_HASH=""
|
||||
SERVER_NAME=""
|
||||
PUBLIC_URL=""
|
||||
TLS_CERT=""
|
||||
TLS_KEY=""
|
||||
@@ -120,6 +121,7 @@ Agents:
|
||||
--server <host:port> agent API of the management server
|
||||
--token <token> one-time enrollment token
|
||||
--ca-hash sha256:<hex> fingerprint of the agent CA
|
||||
--server-name <name> TLS name of the server if it differs from the host of --server
|
||||
(all three are part of the enrollment command
|
||||
shown in the web interface)
|
||||
--kea-source isc|distro Kea from the ISC repository (default, Kea 3.x with
|
||||
@@ -176,6 +178,7 @@ parse_args() {
|
||||
--server) need_arg "$@"; SERVER_ADDR="$2"; shift ;;
|
||||
--token) need_arg "$@"; ENROLL_TOKEN="$2"; shift ;;
|
||||
--ca-hash) need_arg "$@"; CA_HASH="$2"; shift ;;
|
||||
--server-name) need_arg "$@"; SERVER_NAME="$2"; shift ;;
|
||||
--public-url) need_arg "$@"; PUBLIC_URL="$2"; shift ;;
|
||||
--tls-cert) need_arg "$@"; TLS_CERT="$2"; shift ;;
|
||||
--tls-key) need_arg "$@"; TLS_KEY="$2"; shift ;;
|
||||
@@ -519,14 +522,20 @@ unit_exists() { [[ -n "$(systemctl list-unit-files --no-legend "$1.service" 2>/d
|
||||
|
||||
daemon_reload() { run systemctl daemon-reload; }
|
||||
|
||||
# enable_start UNIT enables and starts a unit (idempotent).
|
||||
enable_start() {
|
||||
# enable_only UNIT enables a unit without starting it (idempotent).
|
||||
enable_only() {
|
||||
local u="$1"
|
||||
if ! systemctl is-enabled --quiet "$u" 2>/dev/null; then
|
||||
run systemctl enable "$u"
|
||||
on_rollback systemctl disable "$u"
|
||||
changed "enabled $u"
|
||||
fi
|
||||
}
|
||||
|
||||
# enable_start UNIT enables and starts a unit (idempotent).
|
||||
enable_start() {
|
||||
local u="$1"
|
||||
enable_only "$u"
|
||||
if ! systemctl is-active --quiet "$u"; then
|
||||
run systemctl start "$u" || {
|
||||
[[ $DRY_RUN -eq 1 ]] || systemctl status --no-pager "$u" >>"$LOG_FILE" 2>&1 || true
|
||||
@@ -1101,6 +1110,9 @@ agent_enroll() {
|
||||
local st=""
|
||||
if [[ $DRY_RUN -eq 0 ]]; then
|
||||
st="$(cd / && as_user "$user" "$BINDIR/$bin" status --config "$CONFIG_DIR/$role.yaml" 2>/dev/null || true)"
|
||||
# An identity written by root (manual enrollment) is not readable for
|
||||
# the service user until agent_activate repairs it.
|
||||
[[ "$st" == *"Agent ID:"* ]] || st="$(cd / && "$BINDIR/$bin" status --config "$CONFIG_DIR/$role.yaml" 2>/dev/null || true)"
|
||||
fi
|
||||
[[ "$st" != *"Agent ID:"* ]] || enrolled=1
|
||||
if [[ -n "$ENROLL_TOKEN" ]]; then
|
||||
@@ -1109,17 +1121,37 @@ agent_enroll() {
|
||||
else
|
||||
run_quiet "$bin enroll --server $SERVER_ADDR (as $user)" \
|
||||
runuser -u "$user" -- "$BINDIR/$bin" enroll --config "$CONFIG_DIR/$role.yaml" \
|
||||
--server "$SERVER_ADDR" --token "$ENROLL_TOKEN" --ca-hash "$CA_HASH" ||
|
||||
--server "$SERVER_ADDR" --token "$ENROLL_TOKEN" --ca-hash "$CA_HASH" \
|
||||
${SERVER_NAME:+--server-name "$SERVER_NAME"} --no-start ||
|
||||
die "enrollment of $role failed (see $LOG_FILE)"
|
||||
enrolled=1
|
||||
changed "enrolled $role with $SERVER_ADDR"
|
||||
RESULT_LINES+=("Agent $role registered with $SERVER_ADDR.")
|
||||
fi
|
||||
fi
|
||||
agent_activate "$role" "$user" "$enrolled"
|
||||
}
|
||||
|
||||
# agent_activate ROLE USER ENROLLED repairs the ownership of the agent's
|
||||
# state and enables the unit; an enrolled agent is also started. It runs on
|
||||
# install and upgrade, so an agent enrolled by hand (as root, with the
|
||||
# command of the web interface) becomes a working service.
|
||||
agent_activate() {
|
||||
local role="$1" user="$2" enrolled="$3" bin
|
||||
bin="$(binary_of "$role")"
|
||||
# Files written by an enrollment run as root must belong to the service
|
||||
# user, or the agent cannot read its identity.
|
||||
if [[ $DRY_RUN -eq 0 ]] && [[ -n "$(find "$STATE_DIR/$role" ! -user "$user" -print -quit 2>/dev/null)" ]]; then
|
||||
run chown -R "$user:" "$STATE_DIR/$role"
|
||||
changed "handed $STATE_DIR/$role over to $user"
|
||||
fi
|
||||
if [[ $enrolled -eq 1 ]]; then
|
||||
enable_start "$bin"
|
||||
elif [[ $DRY_RUN -eq 0 ]]; then
|
||||
RESULT_LINES+=("Agent $role is installed but not enrolled: rerun with --role $role --server <host:port> --token <token> --ca-hash sha256:<hex> (enrollment command of the web interface).")
|
||||
else
|
||||
# Enabled anyway: the unit waits for the identity (ConditionPathExists),
|
||||
# so a later enrollment needs no further step after a reboot.
|
||||
enable_only "$bin"
|
||||
[[ $DRY_RUN -eq 1 ]] || RESULT_LINES+=("Agent $role is installed and enabled, but not enrolled yet. Enroll it with the command of the web interface (run as root; it starts the service), or rerun this installer with --role $role --server <host:port> --token <token> --ca-hash sha256:<hex>.")
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1628,9 +1660,34 @@ do_upgrade() {
|
||||
else
|
||||
log " $bin is up to date"
|
||||
fi
|
||||
# Agents enrolled by hand may not be enabled yet (before v0.8.2).
|
||||
case "$r" in
|
||||
agent-dns)
|
||||
dns_detect
|
||||
upgrade_agent "$r" "$BIND_USER"
|
||||
;;
|
||||
agent-dhcp)
|
||||
kea_detect
|
||||
upgrade_agent "$r" "$KEA_USER"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# upgrade_agent ROLE USER activates an agent during an upgrade.
|
||||
upgrade_agent() {
|
||||
local role="$1" user="$2" bin st="" enrolled=0
|
||||
bin="$(binary_of "$role")"
|
||||
if [[ $DRY_RUN -eq 0 ]]; then
|
||||
st="$(cd / && as_user "$user" "$BINDIR/$bin" status --config "$CONFIG_DIR/$role.yaml" 2>/dev/null || true)"
|
||||
# Before the repair the service user may not read an identity written
|
||||
# by root: ask as root as well.
|
||||
[[ "$st" == *"Agent ID:"* ]] || st="$(cd / && "$BINDIR/$bin" status --config "$CONFIG_DIR/$role.yaml" 2>/dev/null || true)"
|
||||
fi
|
||||
[[ "$st" != *"Agent ID:"* ]] || enrolled=1
|
||||
agent_activate "$role" "$user" "$enrolled"
|
||||
}
|
||||
|
||||
do_uninstall() {
|
||||
local r bin
|
||||
for r in "${ROLES[@]}"; do
|
||||
|
||||
@@ -2178,13 +2178,14 @@ components:
|
||||
EnrollmentTokenCreated:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [token, secret, ca_fingerprint, server_address, command]
|
||||
required: [token, secret, ca_fingerprint, server_address, command, install_command]
|
||||
properties:
|
||||
token: { $ref: "#/components/schemas/EnrollmentToken" }
|
||||
secret: { type: string, description: "Complete one-time token \"<id>.<secret>\". Shown only once." }
|
||||
ca_fingerprint: { type: string, description: "sha256:<hex> of the agent CA certificate (DER)." }
|
||||
server_address: { type: string, description: "host:port of the agent API." }
|
||||
command: { type: string, description: "Ready-to-run enrollment command for the agent host." }
|
||||
command: { type: string, description: "Enrollment command for a host where the agent is installed (run as root: it also enables and starts the service)." }
|
||||
install_command: { type: string, description: "Installer call that sets the agent host up and enrolls it (docs/INSTALL.md)." }
|
||||
|
||||
RevisionSummary:
|
||||
type: object
|
||||
|
||||
@@ -349,6 +349,9 @@ type EnrollmentTokenCreated struct {
|
||||
CAFingerprint string `json:"ca_fingerprint"`
|
||||
ServerAddress string `json:"server_address"`
|
||||
Command string `json:"command"`
|
||||
// InstallCommand enrolls through the installer (ADR 0024) on a host
|
||||
// that is not set up yet.
|
||||
InstallCommand string `json:"install_command"`
|
||||
}
|
||||
|
||||
// TokenInput describes a new enrollment token.
|
||||
@@ -374,13 +377,24 @@ func AgentBinary(role string) string {
|
||||
|
||||
// enrollCommand builds the command shown to the administrator.
|
||||
func (s *Service) enrollCommand(role, token string) string {
|
||||
return AgentBinary(role) + " enroll" + s.enrollArgs(token)
|
||||
}
|
||||
|
||||
// installCommand is the installer call that sets the agent host up and
|
||||
// enrolls it (docs/INSTALL.md).
|
||||
func (s *Service) installCommand(role, token string) string {
|
||||
return "sudo ./install.sh --role agent-" + role + s.enrollArgs(token)
|
||||
}
|
||||
|
||||
// enrollArgs are the enrollment options shared by both commands.
|
||||
func (s *Service) enrollArgs(token string) string {
|
||||
addr := s.opts.AgentAPIAddress
|
||||
cmd := AgentBinary(role) + " enroll --server " + addr + " --token " + token + " --ca-hash " + s.opts.PKI.Fingerprint()
|
||||
args := " --server " + addr + " --token " + token + " --ca-hash " + s.opts.PKI.Fingerprint()
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err == nil && len(s.opts.AgentServerNames) > 0 && !slices.Contains(s.opts.AgentServerNames, host) {
|
||||
cmd += " --server-name " + s.opts.AgentServerNames[0]
|
||||
args += " --server-name " + s.opts.AgentServerNames[0]
|
||||
}
|
||||
return cmd
|
||||
return args
|
||||
}
|
||||
|
||||
// CreateEnrollmentToken creates a one-time token for a role.
|
||||
@@ -441,6 +455,7 @@ func (s *Service) CreateEnrollmentToken(ctx context.Context, a *Actor, in TokenI
|
||||
return &EnrollmentTokenCreated{
|
||||
Token: tokenDTO(tk), Secret: full, CAFingerprint: s.opts.PKI.Fingerprint(),
|
||||
ServerAddress: s.opts.AgentAPIAddress, Command: s.enrollCommand(tk.Role, full),
|
||||
InstallCommand: s.installCommand(tk.Role, full),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
+22
-2
@@ -153,8 +153,28 @@ enroll() {
|
||||
# shellcheck disable=SC2016 # expands in the container
|
||||
ex sh -c '! grep -qF -- "$1" /var/log/ddi-suite/install.log' _ "$token" || fail "the token is in the log file"
|
||||
}
|
||||
step "Enroll the agents through the installer"
|
||||
enroll dns
|
||||
step "Agents without token wait enabled"
|
||||
for unit in ddi-agent-dns ddi-agent-dhcp; do
|
||||
ex systemctl is-enabled --quiet "$unit" || fail "$unit is not enabled after the installation"
|
||||
! ex systemctl is-active --quiet "$unit" || fail "$unit runs without enrollment"
|
||||
done
|
||||
echo "ok both agent units are enabled and wait for their identity"
|
||||
|
||||
step "Enroll the DNS agent with the command of the web interface (as root)"
|
||||
# The way a user follows the web interface: run the shown command as root.
|
||||
# It must hand the identity to the BIND user and start the service.
|
||||
cmd="$(api token dns)"
|
||||
read -r -a args <<<"$cmd"
|
||||
ex "${args[@]}" >"$out_file" 2>&1 || { cat "$out_file"; fail "manual enrollment failed"; }
|
||||
cat "$out_file"
|
||||
grep -q "enabled and started" "$out_file" || fail "enroll did not start the service"
|
||||
for _ in $(seq 20); do ex systemctl is-active --quiet ddi-agent-dns && break; sleep 1; done
|
||||
ex systemctl is-active --quiet ddi-agent-dns || fail "ddi-agent-dns is not running after the manual enrollment"
|
||||
owner="$(ex stat -c %U /var/lib/ddi-suite/agent-dns/identity/agent.json)"
|
||||
[[ "$owner" != root ]] || fail "the identity still belongs to root"
|
||||
echo "ok manual enrollment: service running, identity owned by $owner"
|
||||
|
||||
step "Enroll the DHCP agent through the installer"
|
||||
enroll dhcp
|
||||
|
||||
step "Revision 1 reaches BIND and Kea"
|
||||
|
||||
@@ -327,6 +327,8 @@ describe('agents', () => {
|
||||
server_address: 'ddi.example.test:8444',
|
||||
command:
|
||||
'ddi-agent-dhcp enroll --server ddi.example.test:8444 --token 0123456789abcdef.c2VjcmV0 --ca-hash sha256:ab',
|
||||
install_command:
|
||||
'sudo ./install.sh --role agent-dhcp --server ddi.example.test:8444 --token 0123456789abcdef.c2VjcmV0 --ca-hash sha256:ab',
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -344,6 +346,9 @@ describe('agents', () => {
|
||||
expect(await screen.findByTestId('enroll-command')).toHaveTextContent(
|
||||
'ddi-agent-dhcp enroll --server ddi.example.test:8444',
|
||||
);
|
||||
expect(screen.getByTestId('install-command')).toHaveTextContent(
|
||||
'sudo ./install.sh --role agent-dhcp --server ddi.example.test:8444',
|
||||
);
|
||||
const post = calls.find((c) => c.method === 'POST' && c.path === '/api/v1/enrollment-tokens');
|
||||
expect(post?.body).toEqual({ role: 'dhcp', description: 'dhcp1', valid_hours: 24 });
|
||||
});
|
||||
|
||||
+3
-1
@@ -1541,8 +1541,10 @@ export interface components {
|
||||
ca_fingerprint: string;
|
||||
/** @description host:port of the agent API. */
|
||||
server_address: string;
|
||||
/** @description Ready-to-run enrollment command for the agent host. */
|
||||
/** @description Enrollment command for a host where the agent is installed (run as root: it also enables and starts the service). */
|
||||
command: string;
|
||||
/** @description Installer call that sets the agent host up and enrolls it (docs/INSTALL.md). */
|
||||
install_command: string;
|
||||
};
|
||||
RevisionSummary: {
|
||||
number: number;
|
||||
|
||||
@@ -773,7 +773,8 @@
|
||||
"descriptionPlaceholder": "z. B. dns1 RZ Nord",
|
||||
"validHours": "Gültig (Stunden)",
|
||||
"created": "Token erzeugt",
|
||||
"runCommand": "Als root auf dem {{role}}-Server ausführen:",
|
||||
"runInstall": "Neuer {{role}}-Server: Installer ausführen (richtet den Agent ein, registriert ihn und startet den Dienst):",
|
||||
"runCommand": "Agent schon installiert: als root ausführen (registriert und startet den Dienst):",
|
||||
"serverAddress": "Serveradresse",
|
||||
"caFingerprint": "CA-Fingerabdruck",
|
||||
"expires": "Gültig bis",
|
||||
|
||||
@@ -773,7 +773,8 @@
|
||||
"descriptionPlaceholder": "e.g. dns1 data center north",
|
||||
"validHours": "Valid (hours)",
|
||||
"created": "Token created",
|
||||
"runCommand": "Run as root on the {{role}} server:",
|
||||
"runInstall": "New {{role}} server: run the installer (sets the agent up, enrolls it and starts the service):",
|
||||
"runCommand": "Agent already installed: run as root (enrolls it and starts the service):",
|
||||
"serverAddress": "Server address",
|
||||
"caFingerprint": "CA fingerprint",
|
||||
"expires": "Valid until",
|
||||
|
||||
@@ -238,7 +238,12 @@ function TokenResult({ result, onClose }: { result: TokenCreated; onClose: () =>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<p>{t('tokens.runCommand', { role: t(`agents.roles.${result.token.role}`) })}</p>
|
||||
<p>{t('tokens.runInstall', { role: t(`agents.roles.${result.token.role}`) })}</p>
|
||||
<pre className="file-content" data-testid="install-command">
|
||||
{result.install_command}
|
||||
</pre>
|
||||
<CopyButton text={result.install_command} />
|
||||
<p>{t('tokens.runCommand')}</p>
|
||||
<pre className="file-content" data-testid="enroll-command">
|
||||
{result.command}
|
||||
</pre>
|
||||
|
||||
Reference in New Issue
Block a user