The DNS role needs to feed nsupdate scripts on stdin and to log its dynamic updates. command.InputRunner adds RunInput (errors never quote the input); roles implementing core.LogSetter get the core's logger. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
354 lines
10 KiB
Go
354 lines
10 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
// Package core is the role-independent part of the agents: command line,
|
|
// configuration, local state, enrollment, the connection to the management
|
|
// server and the apply pipeline (see docs/ARCHITECTURE.md, section 4, and
|
|
// docs/adr/0021-agent-strecke-m3.md). The roles (BIND, Kea) implement Role.
|
|
package core
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"git.beugsiud.de/beug.k/DDI/internal/agentpki"
|
|
"git.beugsiud.de/beug.k/DDI/internal/buildinfo"
|
|
"git.beugsiud.de/beug.k/DDI/internal/logging"
|
|
)
|
|
|
|
// Main dispatches the agent command line and returns the process exit code.
|
|
// factory creates the role for the run command.
|
|
func Main(binary string, kind Kind, factory RoleFactory, args []string, stdout, stderr io.Writer) int {
|
|
if len(args) == 0 {
|
|
usage(stderr, binary, kind)
|
|
return 2
|
|
}
|
|
c := &cli{binary: binary, kind: kind, factory: factory, stdout: stdout, stderr: stderr}
|
|
switch args[0] {
|
|
case "version", "--version":
|
|
if err := buildinfo.Print(stdout, binary); err != nil {
|
|
return 1
|
|
}
|
|
return 0
|
|
case "help", "-h", "--help":
|
|
usage(stdout, binary, kind)
|
|
return 0
|
|
case "enroll":
|
|
return c.enroll(args[1:])
|
|
case "run":
|
|
return c.run(args[1:])
|
|
case "status":
|
|
return c.status(args[1:])
|
|
default:
|
|
_, _ = fmt.Fprintf(stderr, "%s: unknown command %q\n\n", binary, args[0])
|
|
usage(stderr, binary, kind)
|
|
return 2
|
|
}
|
|
}
|
|
|
|
func usage(w io.Writer, binary string, kind Kind) {
|
|
_, _ = fmt.Fprintf(w, `Usage: %[1]s <command> [flags]
|
|
|
|
Agent role: %[2]s
|
|
|
|
Commands:
|
|
enroll register with the management server
|
|
--server <host:port> --token <token> --ca-hash sha256:<hex>
|
|
[--server-name <name>] [--force]
|
|
run run the agent (until SIGINT/SIGTERM)
|
|
status show identity, revisions and the last result
|
|
version print version information
|
|
help show this help
|
|
|
|
Common flags:
|
|
--config <file> configuration file (default %[3]s)
|
|
`, binary, kind, DefaultConfigPath(kind))
|
|
}
|
|
|
|
type cli struct {
|
|
binary string
|
|
kind Kind
|
|
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) {
|
|
fs := flag.NewFlagSet(c.binary+" "+name, flag.ContinueOnError)
|
|
fs.SetOutput(c.stderr)
|
|
cfg := fs.String("config", DefaultConfigPath(c.kind), "configuration file")
|
|
return fs, cfg
|
|
}
|
|
|
|
func (c *cli) fail(err error) int {
|
|
_, _ = fmt.Fprintf(c.stderr, "%s: %v\n", c.binary, err)
|
|
return 1
|
|
}
|
|
|
|
// parse parses flags and rejects positional arguments.
|
|
func parse(fs *flag.FlagSet, args []string) int {
|
|
if err := fs.Parse(args); err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
return 0
|
|
}
|
|
return 2
|
|
}
|
|
if fs.NArg() > 0 {
|
|
_, _ = fmt.Fprintf(fs.Output(), "unexpected argument %q\n", fs.Arg(0))
|
|
return 2
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func (c *cli) enroll(args []string) int {
|
|
fs, cfgPath := c.flags("enroll")
|
|
var opts EnrollOptions
|
|
fs.StringVar(&opts.Server, "server", "", "agent API address of the management server (host:port)")
|
|
fs.StringVar(&opts.Token, "token", "", "one-time enrollment token")
|
|
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
|
|
}
|
|
if opts.Server == "" || opts.Token == "" || opts.CAHash == "" {
|
|
_, _ = fmt.Fprintln(c.stderr, "enroll needs --server, --token and --ca-hash")
|
|
return 2
|
|
}
|
|
cfg, err := LoadConfig(*cfgPath, c.kind)
|
|
if err != nil {
|
|
return c.fail(err)
|
|
}
|
|
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()
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
id, err := Enroll(ctx, store, opts)
|
|
if err != nil {
|
|
return c.fail(err)
|
|
}
|
|
_, _ = 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 {
|
|
return nil, nil, err
|
|
}
|
|
unlock, err := store.Lock()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return store, unlock, nil
|
|
}
|
|
|
|
func (c *cli) run(args []string) int {
|
|
fs, cfgPath := c.flags("run")
|
|
if code := parse(fs, args); code >= 0 {
|
|
return code
|
|
}
|
|
cfg, err := LoadConfig(*cfgPath, c.kind)
|
|
if err != nil {
|
|
return c.fail(err)
|
|
}
|
|
log, err := logging.New(c.stderr, cfg.Log)
|
|
if err != nil {
|
|
return c.fail(err)
|
|
}
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
if err := runAgent(ctx, c.kind, c.factory, cfg, log); err != nil {
|
|
log.Error("agent stopped", "error", err)
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// runAgent runs the agent until ctx is canceled.
|
|
func runAgent(ctx context.Context, kind Kind, factory RoleFactory, cfg *Config, log *slog.Logger) error {
|
|
if factory == nil {
|
|
return errRoleNotAvailable
|
|
}
|
|
store, unlock, err := openLocked(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer unlock()
|
|
id, err := store.LoadIdentity()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if id.Role != kind {
|
|
return fmt.Errorf("identity in %s belongs to a %s agent", store.Dir(), id.Role)
|
|
}
|
|
role, err := factory(cfg.DecodeRole)
|
|
if err != nil {
|
|
return fmt.Errorf("%s role: %w", kind, err)
|
|
}
|
|
log = log.With("agent_id", id.AgentID, "role", string(kind))
|
|
if ls, ok := role.(LogSetter); ok {
|
|
ls.SetLogger(log)
|
|
}
|
|
hostname, _ := os.Hostname()
|
|
src := NewServerSource(store, id, hostname, log)
|
|
agent, err := NewAgent(kind, cfg, role, store, id, src, log)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
log.Info("agent started", "version", buildinfo.Get().Version, "server", id.Server, "state_dir", store.Dir())
|
|
err = agent.Run(ctx)
|
|
log.Info("agent stopped; the managed service keeps its configuration")
|
|
return err
|
|
}
|
|
|
|
func (c *cli) status(args []string) int {
|
|
fs, cfgPath := c.flags("status")
|
|
if code := parse(fs, args); code >= 0 {
|
|
return code
|
|
}
|
|
cfg, err := LoadConfig(*cfgPath, c.kind)
|
|
if err != nil {
|
|
return c.fail(err)
|
|
}
|
|
store, err := OpenStore(cfg.StateDir)
|
|
if err != nil {
|
|
return c.fail(err)
|
|
}
|
|
if err := writeStatus(c.stdout, store); err != nil {
|
|
return c.fail(err)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// writeStatus prints identity, revisions and the last result.
|
|
func writeStatus(w io.Writer, store *Store) error {
|
|
var b strings.Builder
|
|
line := func(k, format string, a ...any) { fmt.Fprintf(&b, "%-20s %s\n", k+":", fmt.Sprintf(format, a...)) }
|
|
line("State directory", "%s", store.Dir())
|
|
id, err := store.LoadIdentity()
|
|
switch {
|
|
case errors.Is(err, ErrNotEnrolled):
|
|
line("Identity", "not enrolled")
|
|
case err != nil:
|
|
return err
|
|
default:
|
|
leaf := id.Certificate.Leaf
|
|
line("Agent ID", "%s", id.AgentID)
|
|
line("Role", "%s", id.Role)
|
|
line("Server", "%s (TLS name %s)", id.Server, id.ServerName)
|
|
line("Enrolled", "%s", id.EnrolledAt.Format(time.RFC3339))
|
|
line("Certificate", "valid until %s, renewal from %s", leaf.NotAfter.UTC().Format(time.RFC3339),
|
|
renewalDue(leaf).UTC().Format(time.RFC3339))
|
|
line("CA fingerprint", "%s", agentpki.Fingerprint(id.CA))
|
|
}
|
|
st, err := store.LoadState()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
revs, err := store.ListRevisions()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
line("Applied revision", "%s", revOrNone(st.Applied))
|
|
line("Last good revision", "%s", revOrNone(st.LastGood))
|
|
if st.LastUnsuccessful != 0 {
|
|
line("Last unsuccessful", "%d (retried only by a newer revision or an explicit push)", st.LastUnsuccessful)
|
|
}
|
|
nums := make([]string, len(revs))
|
|
for i, n := range revs {
|
|
nums[i] = fmt.Sprint(n)
|
|
}
|
|
line("Stored revisions", "%s", orNone(strings.Join(nums, ", ")))
|
|
if r := st.LastResult; r != nil {
|
|
dep := ""
|
|
if r.DeploymentID != "" {
|
|
dep = ", deployment " + r.DeploymentID
|
|
}
|
|
line("Last result", "revision %d %s at %s (%s%s)", r.Revision, r.Outcome,
|
|
r.FinishedAt.UTC().Format(time.RFC3339), r.Trigger, dep)
|
|
if r.Message != "" {
|
|
line("Message", "%s", r.Message)
|
|
}
|
|
} else {
|
|
line("Last result", "none")
|
|
}
|
|
line("Pending reports", "%d", len(st.PendingReports))
|
|
if st.LeaseSequence != 0 {
|
|
line("Lease sequence", "%d", st.LeaseSequence)
|
|
}
|
|
_, err = io.WriteString(w, b.String())
|
|
return err
|
|
}
|
|
|
|
func revOrNone(n uint64) string {
|
|
if n == 0 {
|
|
return "none"
|
|
}
|
|
return fmt.Sprint(n)
|
|
}
|
|
|
|
func orNone(s string) string {
|
|
if s == "" {
|
|
return "none"
|
|
}
|
|
return s
|
|
}
|