diff --git a/modules/gitea/cmd/forge-account-provider/main.go b/modules/gitea/cmd/forge-account-provider/main.go index 8cbcec69..ddd2b624 100644 --- a/modules/gitea/cmd/forge-account-provider/main.go +++ b/modules/gitea/cmd/forge-account-provider/main.go @@ -65,6 +65,7 @@ func configFromEnv(getenv func(string) string) (Config, error) { HostFile: getenv("MESH_FORGE_HOST_FILE"), OriginsFile: getenv("MESH_GITEA_ORIGINS_FILE"), Port: port, + HostKeyFile: getenv("MESH_GITEA_HOST_KEY_FILE"), // optional Forge: forgehttp.Client{URL: strings.TrimRight(getenv("MESH_GITEA_URL"), "/"), User: getenv("MESH_GITEA_ADMIN_USER"), PasswordFile: getenv("MESH_GITEA_ADMIN_PASSWORD_FILE"), Timeout: 20 * time.Second}, Runner: execRunner{}, diff --git a/modules/gitea/cmd/forge-account-provider/service.go b/modules/gitea/cmd/forge-account-provider/service.go index 0eef366c..2e9716fb 100644 --- a/modules/gitea/cmd/forge-account-provider/service.go +++ b/modules/gitea/cmd/forge-account-provider/service.go @@ -44,10 +44,13 @@ type Config struct { // origins, one per line. HostFile, OriginsFile string // Port is where the forge's sshd is published on the mesh. - Port int - Forge forgehttp.Client - Runner Runner - Log func(format string, a ...any) + Port int + // HostKeyFile is the forge's own ed25519 host key (the .pub file in its data directory). It is read first; when it + // is unset or unreadable the forge's sshd is asked with ssh-keyscan instead (issue 1939). + HostKeyFile string + Forge forgehttp.Client + Runner Runner + Log func(format string, a ...any) } // Service answers the consumers of the provision `forge-account` (novox/hq ADR 0399, ADR 0400; issue 955): for each @@ -448,14 +451,30 @@ func (s *Service) httpsBase() (string, error) { return "", fmt.Errorf("%s names no https origin", s.cfg.OriginsFile) } -// scan reads the forge's ed25519 host key from its own sshd and writes it as the machines' known_hosts line. +// scan gives the forge's ed25519 host key as the machines' known_hosts line. The key the forge itself holds is read +// from its file first; only when that cannot be read is the forge's sshd asked over the network, and when that fails +// too (the forge's container is restarting) the key read before is kept, as a host key does not change (issue 1939). func (s *Service) scan(ctx context.Context) (string, error) { host, err := s.hostName() if err != nil { return "", err } + if s.cfg.HostKeyFile != "" { + raw, ferr := os.ReadFile(s.cfg.HostKeyFile) + if ferr == nil { + if line, perr := knownHostLine("127.0.0.1 "+strings.TrimSpace(string(raw)), host, s.cfg.Port); perr == nil { + return line, nil + } + } + } out, err := s.cfg.Runner.Run(ctx, "ssh-keyscan", "-t", "ed25519", "-p", fmt.Sprint(s.cfg.Port), "127.0.0.1") if err != nil { + s.mu.Lock() + kept := s.known + s.mu.Unlock() + if kept != "" { + return kept, nil + } return "", fmt.Errorf("the forge's ssh host key cannot be read: %w", err) } return knownHostLine(string(out), host, s.cfg.Port) diff --git a/modules/gitea/cmd/forge-account-provider/service_test.go b/modules/gitea/cmd/forge-account-provider/service_test.go index 8f3df6ae..7f47709a 100644 --- a/modules/gitea/cmd/forge-account-provider/service_test.go +++ b/modules/gitea/cmd/forge-account-provider/service_test.go @@ -696,3 +696,42 @@ func TestNothingIsSaidForAPerNodeUserWithoutMeshKeys(t *testing.T) { } } } + +// The forge's own host key file is read first: no scan over the network is needed (issue 1939). +func TestTheHostKeyIsReadFromTheForgesOwnFile(t *testing.T) { + r := newRig(t) + f := filepath.Join(t.TempDir(), "ssh_host_ed25519_key.pub") + if err := os.WriteFile(f, []byte("ssh-ed25519 AAAAC3FILEKEY forge@host\n"), 0o644); err != nil { + t.Fatal(err) + } + r.svc.cfg.HostKeyFile = f + r.runner.err = errors.New("sshd down") + got, err := r.svc.scan(context.Background()) + if err != nil || got != "[novox.internal]:222 ssh-ed25519 AAAAC3FILEKEY" { + t.Fatalf("scan = %q, %v", got, err) + } + if r.runner.args != nil { + t.Errorf("the forge's sshd was asked: %v", r.runner.args) + } +} + +// A file that cannot be read falls back to the forge's sshd. +func TestAnUnreadableHostKeyFileFallsBackToTheScan(t *testing.T) { + r := newRig(t) + r.svc.cfg.HostKeyFile = filepath.Join(t.TempDir(), "missing.pub") + got, err := r.svc.scan(context.Background()) + if err != nil || got != "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY" { + t.Fatalf("scan = %q, %v", got, err) + } +} + +// While the forge restarts, a key read before is kept: the sshd is not up, the key has not changed. +func TestARestartingForgeKeepsTheKeyReadBefore(t *testing.T) { + r := newRig(t) + r.svc.known = "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY" + r.runner.err = errors.New("connection refused") + got, err := r.svc.scan(context.Background()) + if err != nil || got != r.svc.known { + t.Fatalf("scan = %q, %v", got, err) + } +} diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 1ac1aeb8..474d6d55 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -537,6 +537,7 @@ "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", "MESH_GITEA_ORIGINS_FILE": "${dir:mesh-state}/origins", "MESH_GITEA_SSH_PORT": "222", + "MESH_GITEA_HOST_KEY_FILE": "${dir:data}/ssh/ssh_host_ed25519_key.pub", "MESH_FORGE_HOST_FILE": "/etc/gitea/forge-host" } }