From ad5a0f8e44371c265952b7c8fda3aaabd9b99a48 Mon Sep 17 00:00:00 2001 From: fix-955-ssh Date: Fri, 16 Oct 2026 22:43:51 +0200 Subject: [PATCH] Place the forge's known host, SSH Host and git rewrite (issue 955 S3) --- .../cmd/ssh-client-tools/templates_test.go | 87 +++++++++++++++++++ modules/ssh-client/files/forge | 5 ++ modules/ssh-client/files/gitconfig | 2 + modules/ssh-client/files/known_hosts | 1 + modules/ssh-client/module.json | 33 +++++++ 5 files changed, 128 insertions(+) create mode 100644 modules/ssh-client/files/forge create mode 100644 modules/ssh-client/files/gitconfig create mode 100644 modules/ssh-client/files/known_hosts diff --git a/modules/ssh-client/cmd/ssh-client-tools/templates_test.go b/modules/ssh-client/cmd/ssh-client-tools/templates_test.go index dd06a356..ea270636 100644 --- a/modules/ssh-client/cmd/ssh-client-tools/templates_test.go +++ b/modules/ssh-client/cmd/ssh-client-tools/templates_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" ) @@ -88,3 +89,89 @@ func TestManifestKeepsKeysAndRequiresForgeAccount(t *testing.T) { t.Errorf("provides = %v, want none (no ssh-key provision yet)", m.Provides) } } + +// Issue 955, area S3: the three files the forge account fills in — known_hosts, the forge's Host, git's rewrite. +var sampleBound = map[string]string{ + "known_host": "[novox.internal]:222 ssh-ed25519 AAAAC3Nza", + "ssh_host": "novox.internal", + "ssh_port": "222", + "ssh_base": "ssh://git@novox.internal:222/", + "https_base": "https://git.novox.be/", +} + +// renderBound fills ${bound:forge-account:} with the sample values, as the provisioner client does. +func renderBound(t *testing.T, s string) string { + t.Helper() + for k, v := range sampleBound { + s = strings.ReplaceAll(s, "${bound:forge-account:"+k+"}", v) + } + if strings.Contains(s, "${") { + t.Errorf("unfilled placeholder in %q", s) + } + return s +} + +func resourceByPath(t *testing.T, path string) map[string]any { + t.Helper() + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Resources []map[string]any `json:"resources"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, r := range m.Resources { + if r["path"] == path { + if r["owner"] != "${machine:account}" { + t.Errorf("%s is owned by %v, not the machine's account", path, r["owner"]) + } + return r + } + } + t.Fatalf("no resource at %s", path) + return nil +} + +func TestForgeFilesRenderFromTheBoundValues(t *testing.T) { + home := "${machine:account-home}" + kh := resourceByPath(t, home+"/.ssh/known_hosts") + if kh["into"] != "block" { + t.Errorf("known_hosts is %v, want a block", kh["into"]) + } + if got := strings.TrimSpace(renderBound(t, templateText(t, kh))); got != "[novox.internal]:222 ssh-ed25519 AAAAC3Nza" { + t.Errorf("known_hosts block = %q", got) + } + + forge := resourceByPath(t, home+"/.ssh/config.d/10-forge") + if forge["into"] != nil { + t.Errorf("10-forge is a block, want the whole file") + } + want := "Host novox.internal\n Port 222\n User git\n IdentityFile ~/.ssh/mesh_ed25519\n IdentitiesOnly yes\n" + if got := renderBound(t, templateText(t, forge)); got != want { + t.Errorf("10-forge = %q, want %q", got, want) + } + + resourceByPath(t, home+"/.config/git") // the directory + g := resourceByPath(t, home+"/.config/git/config") + if g["into"] != "block" { + t.Errorf("git config is %v, want a block", g["into"]) + } + wantGit := "[url \"ssh://git@novox.internal:222/\"]\n\tinsteadOf = https://git.novox.be/\n" + if got := renderBound(t, templateText(t, g)); got != wantGit { + t.Errorf("git config block = %q, want %q", got, wantGit) + } +} + +// The mesh's region in ~/.ssh/config brings in config.d/*, so 10-forge is read. +func TestConfigIncludesTheDropInDirectory(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "files", "config")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "Include ~/.ssh/config.d/*") { + t.Errorf("files/config does not include config.d/*") + } +} diff --git a/modules/ssh-client/files/forge b/modules/ssh-client/files/forge new file mode 100644 index 00000000..2da188cf --- /dev/null +++ b/modules/ssh-client/files/forge @@ -0,0 +1,5 @@ +Host ${bound:forge-account:ssh_host} + Port ${bound:forge-account:ssh_port} + User git + IdentityFile ~/.ssh/mesh_ed25519 + IdentitiesOnly yes diff --git a/modules/ssh-client/files/gitconfig b/modules/ssh-client/files/gitconfig new file mode 100644 index 00000000..d13ccb34 --- /dev/null +++ b/modules/ssh-client/files/gitconfig @@ -0,0 +1,2 @@ +[url "${bound:forge-account:ssh_base}"] + insteadOf = ${bound:forge-account:https_base} diff --git a/modules/ssh-client/files/known_hosts b/modules/ssh-client/files/known_hosts new file mode 100644 index 00000000..aa12b501 --- /dev/null +++ b/modules/ssh-client/files/known_hosts @@ -0,0 +1 @@ +${bound:forge-account:known_host} diff --git a/modules/ssh-client/module.json b/modules/ssh-client/module.json index 126486a5..44cc6086 100644 --- a/modules/ssh-client/module.json +++ b/modules/ssh-client/module.json @@ -51,6 +51,39 @@ "into": "block", "at": "start", "template": "files/config" + }, + { + "id": "known-hosts", + "type": "file", + "path": "${machine:account-home}/.ssh/known_hosts", + "owner": "${machine:account}", + "mode": "0644", + "into": "block", + "template": "files/known_hosts" + }, + { + "id": "forge-host", + "type": "file", + "path": "${machine:account-home}/.ssh/config.d/10-forge", + "owner": "${machine:account}", + "mode": "0600", + "template": "files/forge" + }, + { + "id": "git-config-dir", + "type": "directory", + "path": "${machine:account-home}/.config/git", + "owner": "${machine:account}", + "mode": "0755" + }, + { + "id": "git-config", + "type": "file", + "path": "${machine:account-home}/.config/git/config", + "owner": "${machine:account}", + "mode": "0644", + "into": "block", + "template": "files/gitconfig" } ], "facts": {