diff --git a/agent/core/cli.go b/agent/core/cli.go index d545793..5c33418 100644 --- a/agent/core/cli.go +++ b/agent/core/cli.go @@ -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:)") 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 " + } + } + 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 { diff --git a/agent/core/cli_test.go b/agent/core/cli_test.go index 7d5e238..0bec838 100644 --- a/agent/core/cli_test.go +++ b/agent/core/cli_test.go @@ -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()) + } +} diff --git a/agent/core/service_other.go b/agent/core/service_other.go new file mode 100644 index 0000000..0439794 --- /dev/null +++ b/agent/core/service_other.go @@ -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 } diff --git a/agent/core/service_unix.go b/agent/core/service_unix.go new file mode 100644 index 0000000..ce73962 --- /dev/null +++ b/agent/core/service_unix.go @@ -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 +} diff --git a/deploy/systemd/agent-dhcp.service.in b/deploy/systemd/agent-dhcp.service.in index 637e03d..f1fbf6c 100644 --- a/deploy/systemd/agent-dhcp.service.in +++ b/deploy/systemd/agent-dhcp.service.in @@ -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 diff --git a/deploy/systemd/agent-dns.service.in b/deploy/systemd/agent-dns.service.in index b3f8979..df337d0 100644 --- a/deploy/systemd/agent-dns.service.in +++ b/deploy/systemd/agent-dns.service.in @@ -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 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 0e3c90b..34aa3a4 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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 :8444 --token --ca-hash sha256:`. 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 :8444 --token --ca-hash sha256:` 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 --ca-hash sha256: ``` - 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 ` | Release-Artefakte (Tarballs, `SHA256SUMS`, ggf. `SHA256SUMS.sig`) aus einem Verzeichnis statt Download (offline, Tests) | | `--git-token ` | Zugriffstoken, falls Releases eine Anmeldung brauchen (alternativ `DDI_GIT_TOKEN`); wird nie geloggt | | `--server `, `--token `, `--ca-hash sha256:` | Registrierung eines Agents (Werte aus dem Enrollment-Befehl der Oberfläche) | +| `--server-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 der Oberfläche, Standard `https://:8443`; ihr Hostname ist auch die Agent-API-Adresse (`:8444`) | | `--tls-cert `, `--tls-key ` | eigenes Zertifikat (PEM, mit Kette) und Schlüssel; ohne erzeugt der Installer ein selbstsigniertes Zertifikat | diff --git a/docs/adr/0024-auslieferung-installer-m6.md b/docs/adr/0024-auslieferung-installer-m6.md index b05781d..284616d 100644 --- a/docs/adr/0024-auslieferung-installer-m6.md +++ b/docs/adr/0024-auslieferung-installer-m6.md @@ -90,3 +90,4 @@ Brief §10 verlangt `scripts/install.sh` für die Rollen `server`, `agent-dns` u - `DDI_RELEASE_BASE_URL` darf `` 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`. diff --git a/scripts/install.sh b/scripts/install.sh index 5c60ce3..dcac81f 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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 agent API of the management server --token one-time enrollment token --ca-hash sha256: fingerprint of the agent CA + --server-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 --token --ca-hash sha256: (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 --token --ca-hash sha256:.") 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 diff --git a/server/api/openapi.yaml b/server/api/openapi.yaml index c6b604f..53290d5 100644 --- a/server/api/openapi.yaml +++ b/server/api/openapi.yaml @@ -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 \".\". Shown only once." } ca_fingerprint: { type: string, description: "sha256: 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 diff --git a/server/internal/service/agents.go b/server/internal/service/agents.go index 0bca486..5613e75 100644 --- a/server/internal/service/agents.go +++ b/server/internal/service/agents.go @@ -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 } diff --git a/test/install/run.sh b/test/install/run.sh index 32d6bda..8c5637d 100755 --- a/test/install/run.sh +++ b/test/install/run.sh @@ -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" diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 051c1b1..9e9f971 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -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 }); }); diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index 3d3431c..933ed5b 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -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; diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 06c76b3..160bf85 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -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", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index f7d02b5..a160a9f 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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", diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx index f920421..e93a307 100644 --- a/web/src/pages/AgentsPage.tsx +++ b/web/src/pages/AgentsPage.tsx @@ -238,7 +238,12 @@ function TokenResult({ result, onClose }: { result: TokenCreated; onClose: () => } > -

{t('tokens.runCommand', { role: t(`agents.roles.${result.token.role}`) })}

+

{t('tokens.runInstall', { role: t(`agents.roles.${result.token.role}`) })}

+
+        {result.install_command}
+      
+ +

{t('tokens.runCommand')}

         {result.command}