| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | // TestEmbeddedUnits guards the embedded systemd units against drift — the |
| 11 | // instance template must carry the EnvironmentFile add-instance relies on. |
| 12 | func TestEmbeddedUnits(t *testing.T) { |
| 13 | if !strings.Contains(unitRouter, "ExecStart=/opt/lchat/lchat router") { |
| 14 | t.Error("router unit missing its ExecStart line") |
| 15 | } |
| 16 | if !strings.Contains(unitInstance, "EnvironmentFile=-/data/%i.env") { |
| 17 | t.Error("instance unit missing EnvironmentFile directive") |
| 18 | } |
| 19 | // Execs the per-instance live symlink (versioned deploys), not one shared binary. |
| 20 | if !strings.Contains(unitInstance, "ExecStart=/opt/lchat/live/%i --socket /run/lchat/%i.sock --db /data/%i.db") { |
| 21 | t.Error("instance unit missing its versioned ExecStart line") |
| 22 | } |
| 23 | // The bot execs the SAME per-instance live symlink as its instance, so its |
| 24 | // binary tracks the app version (deployHostVersion restarts it on a move). |
| 25 | if !strings.Contains(unitBot, "ExecStart=/opt/lchat/live/%i bot run") { |
| 26 | t.Error("bot unit missing its versioned `bot run` ExecStart line") |
| 27 | } |
| 28 | if !strings.Contains(unitBot, "EnvironmentFile=-/data/%i-bot.env") { |
| 29 | t.Error("bot unit missing its EnvironmentFile") |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | // TestEmbeddedCollectors guards that the status collectors ride the binary: the |
| 34 | // scripts + their units are embedded non-empty and match their source (the box |
| 35 | // can no longer scp them in — the whole point of this install path). |
| 36 | func TestEmbeddedCollectors(t *testing.T) { |
| 37 | // A sentinel line from each source script — proves we embedded the real file, |
| 38 | // not an empty/placeholder string, and pins the /opt/lchat ExecStart path the |
| 39 | // units depend on. |
| 40 | if !strings.Contains(scriptSSHLedger, "Installed on the box at exactly /opt/lchat/ssh-ledger-collect.sh") { |
| 41 | t.Error("scriptSSHLedger missing its source sentinel line") |
| 42 | } |
| 43 | if !strings.Contains(scriptHardening, "Public fleet hardening posture — the box-side collector") { |
| 44 | t.Error("scriptHardening missing its source sentinel line") |
| 45 | } |
| 46 | // Both scripts are bash — the shebang must survive the embed so systemd can |
| 47 | // exec them. |
| 48 | for name, s := range map[string]string{"ssh-ledger": scriptSSHLedger, "hardening": scriptHardening} { |
| 49 | if !strings.HasPrefix(s, "#!/usr/bin/env bash") { |
| 50 | t.Errorf("%s script missing its shebang", name) |
| 51 | } |
| 52 | } |
| 53 | // The four units + their ExecStart / timer markers. |
| 54 | for name, u := range map[string]string{ |
| 55 | "ssh-ledger.service": unitSSHLedger, "ssh-ledger.timer": unitSSHLedgerTimer, |
| 56 | "hardening.service": unitHardening, "hardening.timer": unitHardeningTimer, |
| 57 | } { |
| 58 | if strings.TrimSpace(u) == "" { |
| 59 | t.Errorf("unit %s embedded empty", name) |
| 60 | } |
| 61 | } |
| 62 | if !strings.Contains(unitSSHLedger, "ExecStart=/opt/lchat/ssh-ledger-collect.sh") { |
| 63 | t.Error("ssh-ledger unit missing its ExecStart line") |
| 64 | } |
| 65 | if !strings.Contains(unitHardening, "ExecStart=/opt/lchat/hardening-posture.sh") { |
| 66 | t.Error("hardening unit missing its ExecStart line") |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | // TestWriteCollectorFiles drives the shared install helper into temp dirs (no |
| 71 | // systemctl, no /opt/lchat) and asserts every file lands at the right path with |
| 72 | // the right mode: scripts executable (0755), units + pages 0644, and the viewer |
| 73 | // pages written basename-only into <app-dir>/status-pages. |
| 74 | func TestWriteCollectorFiles(t *testing.T) { |
| 75 | appDir := t.TempDir() |
| 76 | unitDir := t.TempDir() |
| 77 | dataDir := t.TempDir() |
| 78 | |
| 79 | writeCollectorFiles(appDir, unitDir, dataDir) |
| 80 | |
| 81 | // The collectors' state dirs must exist before their units first start: a |
| 82 | // missing ReadWritePaths target fails the unit's namespace setup — even its |
| 83 | // `+`-prefixed ExecStartPre mkdir (the first-real-run failure, 2026-07-19). |
| 84 | for _, d := range []string{"ssh-ledger", "hardening"} { |
| 85 | info, err := os.Stat(filepath.Join(dataDir, d)) |
| 86 | if err != nil || !info.IsDir() { |
| 87 | t.Errorf("collector state dir %s not created: %v", d, err) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | // Scripts: present, executable, and byte-identical to the embedded source. |
| 92 | for _, tc := range []struct { |
| 93 | name string |
| 94 | want string |
| 95 | }{ |
| 96 | {"ssh-ledger-collect.sh", scriptSSHLedger}, |
| 97 | {"hardening-posture.sh", scriptHardening}, |
| 98 | } { |
| 99 | p := filepath.Join(appDir, tc.name) |
| 100 | assertFileMode(t, p, 0o755) |
| 101 | got, err := os.ReadFile(p) |
| 102 | if err != nil { |
| 103 | t.Fatalf("read %s: %v", tc.name, err) |
| 104 | } |
| 105 | if string(got) != tc.want { |
| 106 | t.Errorf("%s content differs from embedded source", tc.name) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | // Units: present at 0644 in the unit dir. |
| 111 | for _, u := range []string{ |
| 112 | "lchat-ssh-ledger.service", "lchat-ssh-ledger.timer", |
| 113 | "lchat-hardening.service", "lchat-hardening.timer", |
| 114 | } { |
| 115 | assertFileMode(t, filepath.Join(unitDir, u), 0o644) |
| 116 | } |
| 117 | |
| 118 | // Pages: every embedded .html + status.css, basename-only, at 0644 — and |
| 119 | // nothing else (no ssh/ dir, no JSON). |
| 120 | pagesDir := filepath.Join(appDir, "status-pages") |
| 121 | for _, page := range []string{ |
| 122 | "index.html", "hardening.html", "ssh.html", "music.html", |
| 123 | "status.css", "status-nav.js", |
| 124 | } { |
| 125 | assertFileMode(t, filepath.Join(pagesDir, page), 0o644) |
| 126 | } |
| 127 | for _, unexpected := range []string{"carbon.json", "ssh-operators.json", "icon-192.png"} { |
| 128 | if _, err := os.Stat(filepath.Join(pagesDir, unexpected)); err == nil { |
| 129 | t.Errorf("status-pages should not contain generated/non-embedded %s", unexpected) |
| 130 | } |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | // TestEnableCollectorTimers checks the helper enables + fires both timers. |
| 135 | func TestEnableCollectorTimers(t *testing.T) { |
| 136 | oldSysctl := systemctl |
| 137 | t.Cleanup(func() { systemctl = oldSysctl }) |
| 138 | var calls []string |
| 139 | systemctl = func(args ...string) error { |
| 140 | calls = append(calls, strings.Join(args, " ")) |
| 141 | return nil |
| 142 | } |
| 143 | |
| 144 | enableCollectorTimers() |
| 145 | |
| 146 | want := []string{ |
| 147 | "enable --now lchat-ssh-ledger.timer", |
| 148 | "enable --now lchat-hardening.timer", |
| 149 | } |
| 150 | if len(calls) != len(want) { |
| 151 | t.Fatalf("systemctl calls = %v, want %v", calls, want) |
| 152 | } |
| 153 | for i, w := range want { |
| 154 | if calls[i] != w { |
| 155 | t.Errorf("call %d = %q, want %q", i, calls[i], w) |
| 156 | } |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // TestSyncLinks: the redirector runs on the apex box and nowhere else. |
| 161 | // enable+restart (never `enable --now`) so the installer's update path |
| 162 | // re-execs the new binary rather than leaving the old process up; disable |
| 163 | // --now on every other box so a fleet doesn't carry idle redirectors. |
| 164 | func TestSyncLinks(t *testing.T) { |
| 165 | for _, tc := range []struct { |
| 166 | name string |
| 167 | apex bool |
| 168 | want []string |
| 169 | }{ |
| 170 | {"apex box runs it", true, []string{"enable lchat-links", "restart lchat-links"}}, |
| 171 | {"every other box does not", false, []string{"disable --now lchat-links"}}, |
| 172 | } { |
| 173 | t.Run(tc.name, func(t *testing.T) { |
| 174 | oldSysctl := systemctl |
| 175 | t.Cleanup(func() { systemctl = oldSysctl }) |
| 176 | var calls []string |
| 177 | systemctl = func(args ...string) error { |
| 178 | calls = append(calls, strings.Join(args, " ")) |
| 179 | return nil |
| 180 | } |
| 181 | |
| 182 | syncLinks(tc.apex) |
| 183 | |
| 184 | if len(calls) != len(tc.want) { |
| 185 | t.Fatalf("systemctl calls = %v, want %v", calls, tc.want) |
| 186 | } |
| 187 | for i, w := range tc.want { |
| 188 | if calls[i] != w { |
| 189 | t.Errorf("call %d = %q, want %q", i, calls[i], w) |
| 190 | } |
| 191 | } |
| 192 | }) |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | // TestSyncLinksSurvivesFailure: short links are one optional service, so a box |
| 197 | // that can't drive the unit must still finish bootstrapping rather than die(). |
| 198 | func TestSyncLinksSurvivesFailure(t *testing.T) { |
| 199 | oldSysctl := systemctl |
| 200 | t.Cleanup(func() { systemctl = oldSysctl }) |
| 201 | systemctl = func(args ...string) error { return os.ErrPermission } |
| 202 | |
| 203 | syncLinks(true) // must return normally rather than exiting |
| 204 | syncLinks(false) // ditto for the disable path |
| 205 | } |
| 206 | |
| 207 | // TestLinksRouted: the registry is what decides whether this box is the apex, |
| 208 | // and it is matched on the SOCKET, not a hostname — that is what lets the same |
| 209 | // code recognise 11ch.at in production and dev.11ch.at on the dev mesh without |
| 210 | // either being named here. |
| 211 | // |
| 212 | // It is also what makes re-running bootstrap safe: the apex box keeps its |
| 213 | // route, so a later plain `bootstrap` sees routed and leaves short links up, |
| 214 | // instead of silently switching them off the way a flag-keyed design would. |
| 215 | func TestLinksRouted(t *testing.T) { |
| 216 | newRegistry := func(t *testing.T, routes ...Route) string { |
| 217 | t.Helper() |
| 218 | path := filepath.Join(t.TempDir(), "registry.db") |
| 219 | reg, err := openRegistry(path) |
| 220 | if err != nil { |
| 221 | t.Fatalf("open registry: %v", err) |
| 222 | } |
| 223 | for _, rt := range routes { |
| 224 | if err := reg.upsert(rt); err != nil { |
| 225 | t.Fatalf("seed %s: %v", rt.Host, err) |
| 226 | } |
| 227 | } |
| 228 | return path |
| 229 | } |
| 230 | |
| 231 | t.Run("no routes at all", func(t *testing.T) { |
| 232 | if linksRouted(newRegistry(t)) { |
| 233 | t.Error("an empty registry must not read as the apex") |
| 234 | } |
| 235 | }) |
| 236 | |
| 237 | t.Run("instances only", func(t *testing.T) { |
| 238 | path := newRegistry(t, |
| 239 | Route{Host: "a.example.com", Kind: "instance", Socket: "/run/lchat/a.example.com.sock", Status: "ready"}, |
| 240 | Route{Host: "home.example.com", Kind: "service", Socket: "/run/lchat/home.sock", Status: "ready"}, |
| 241 | ) |
| 242 | if linksRouted(path) { |
| 243 | t.Error("a box with instances and other services must not read as the apex") |
| 244 | } |
| 245 | }) |
| 246 | |
| 247 | t.Run("production apex", func(t *testing.T) { |
| 248 | path := newRegistry(t, |
| 249 | Route{Host: "a.example.com", Kind: "instance", Socket: "/run/lchat/a.example.com.sock", Status: "ready"}, |
| 250 | Route{Host: "11ch.at", Kind: "service", Socket: linksSocket, Status: "ready"}, |
| 251 | ) |
| 252 | if !linksRouted(path) { |
| 253 | t.Error("a route pointing at the links socket means this box IS the apex") |
| 254 | } |
| 255 | }) |
| 256 | |
| 257 | t.Run("dev apex — recognised without being named", func(t *testing.T) { |
| 258 | path := newRegistry(t, Route{Host: "dev.11ch.at", Kind: "service", Socket: linksSocket, Status: "ready"}) |
| 259 | if !linksRouted(path) { |
| 260 | t.Error("the apex is identified by socket, so any hostname must work") |
| 261 | } |
| 262 | }) |
| 263 | |
| 264 | t.Run("unreadable registry is not the apex", func(t *testing.T) { |
| 265 | // Fail safe: an idle box beats starting a service we couldn't justify. |
| 266 | if linksRouted(filepath.Join(t.TempDir(), "nonexistent", "registry.db")) { |
| 267 | t.Error("an unreadable registry must not read as the apex") |
| 268 | } |
| 269 | }) |
| 270 | |
| 271 | // The fresh-box case, and the one that actually guards a footgun: /data |
| 272 | // exists but the registry does not yet. openRegistry CREATES the database, |
| 273 | // and this read runs as root on every plain bootstrap (including every |
| 274 | // self-hoster's install.sh) — while the router, running as the lchat user, |
| 275 | // creates the registry itself moments earlier. If this read won that race |
| 276 | // it would leave a root-owned registry the router cannot write, breaking |
| 277 | // all routing on a fresh box. So it must answer "not the apex" WITHOUT |
| 278 | // creating anything. |
| 279 | t.Run("absent registry is not the apex and is not created", func(t *testing.T) { |
| 280 | dir := t.TempDir() |
| 281 | path := filepath.Join(dir, "registry.db") |
| 282 | |
| 283 | if linksRouted(path) { |
| 284 | t.Error("an absent registry must not read as the apex") |
| 285 | } |
| 286 | |
| 287 | entries, err := os.ReadDir(dir) |
| 288 | if err != nil { |
| 289 | t.Fatalf("read dir: %v", err) |
| 290 | } |
| 291 | for _, e := range entries { |
| 292 | t.Errorf("linksRouted created %s — a read must never create the registry", e.Name()) |
| 293 | } |
| 294 | }) |
| 295 | } |
| 296 | |
| 297 | // TestRestartControlUnitsIncludesLinks: the units that exec /opt/lchat/lchat |
| 298 | // directly (rather than a versioned live symlink) keep their OLD inode until |
| 299 | // something restarts them, so every one of them must ride the updater's |
| 300 | // restart pass — otherwise the redirector is the one service that never picks |
| 301 | // up a new build. |
| 302 | func TestRestartControlUnitsIncludesLinks(t *testing.T) { |
| 303 | oldSysctl := systemctl |
| 304 | t.Cleanup(func() { systemctl = oldSysctl }) |
| 305 | var calls []string |
| 306 | systemctl = func(args ...string) error { |
| 307 | calls = append(calls, strings.Join(args, " ")) |
| 308 | return nil |
| 309 | } |
| 310 | |
| 311 | restartControlUnits() |
| 312 | |
| 313 | want := []string{ |
| 314 | "try-restart lchat-control.service", |
| 315 | "try-restart lchat-cloud.service", |
| 316 | "try-restart lchat-links.service", |
| 317 | } |
| 318 | if len(calls) != len(want) { |
| 319 | t.Fatalf("systemctl calls = %v, want %v", calls, want) |
| 320 | } |
| 321 | for i, w := range want { |
| 322 | if calls[i] != w { |
| 323 | t.Errorf("call %d = %q, want %q", i, calls[i], w) |
| 324 | } |
| 325 | } |
| 326 | } |
| 327 | |
| 328 | // TestSetUpdateTimer: the off path must DISABLE, not merely skip enabling. A |
| 329 | // box bootstrapped normally once already has the timer on, and on the dev mesh |
| 330 | // an updater pass would replace unmerged code with the current stable build — |
| 331 | // so the end state has to follow the flag, not the box's history. |
| 332 | func TestSetUpdateTimer(t *testing.T) { |
| 333 | for _, tc := range []struct { |
| 334 | name string |
| 335 | on bool |
| 336 | want string |
| 337 | }{ |
| 338 | {"on enables", true, "enable --now lchat-update.timer"}, |
| 339 | {"off disables", false, "disable --now lchat-update.timer"}, |
| 340 | } { |
| 341 | t.Run(tc.name, func(t *testing.T) { |
| 342 | oldSysctl := systemctl |
| 343 | t.Cleanup(func() { systemctl = oldSysctl }) |
| 344 | var calls []string |
| 345 | systemctl = func(args ...string) error { |
| 346 | calls = append(calls, strings.Join(args, " ")) |
| 347 | return nil |
| 348 | } |
| 349 | |
| 350 | setUpdateTimer(tc.on) |
| 351 | |
| 352 | if len(calls) != 1 || calls[0] != tc.want { |
| 353 | t.Errorf("systemctl calls = %v, want [%q]", calls, tc.want) |
| 354 | } |
| 355 | }) |
| 356 | } |
| 357 | } |
| 358 | |
| 359 | // TestLinksUnitShape guards the embedded unit against drift: it must exec the |
| 360 | // links subcommand on the socket bootstrap's route registration expects, and |
| 361 | // must NOT carry an EnvironmentFile (it holds no secrets — the reason enabling |
| 362 | // it everywhere is safe). |
| 363 | func TestLinksUnitShape(t *testing.T) { |
| 364 | if !strings.Contains(unitLinks, "ExecStart=/opt/lchat/lchat links run --socket /run/lchat/links.sock") { |
| 365 | t.Error("links unit missing its ExecStart line (socket path is the route contract)") |
| 366 | } |
| 367 | if strings.Contains(unitLinks, "EnvironmentFile") { |
| 368 | t.Error("links unit should carry no EnvironmentFile — it holds no secrets") |
| 369 | } |
| 370 | } |
| 371 | |
| 372 | // assertFileMode asserts a file exists with exactly the given permission bits. |
| 373 | func assertFileMode(t *testing.T, path string, want os.FileMode) { |
| 374 | t.Helper() |
| 375 | fi, err := os.Stat(path) |
| 376 | if err != nil { |
| 377 | t.Fatalf("stat %s: %v", path, err) |
| 378 | } |
| 379 | if got := fi.Mode().Perm(); got != want { |
| 380 | t.Errorf("%s mode = %o, want %o", path, got, want) |
| 381 | } |
| 382 | } |
| 383 | |
| 384 | // TestOperatorsExist pins the lockdown gate: only a group file whose |
| 385 | // lchat-operators entry actually lists members opens it — a missing file, |
| 386 | // missing group, or empty member list must all read as "no operators". |
| 387 | func TestOperatorsExist(t *testing.T) { |
| 388 | cases := map[string]struct { |
| 389 | content string |
| 390 | want bool |
| 391 | }{ |
| 392 | "members": {"root:x:0:\nlchat-operators:x:990:paul,james\n", true}, |
| 393 | "one member": {"lchat-operators:x:990:paul\n", true}, |
| 394 | "empty list": {"lchat-operators:x:990:\n", false}, |
| 395 | "blank list": {"lchat-operators:x:990: \n", false}, |
| 396 | "no group": {"root:x:0:\nlchat:x:999:\n", false}, |
| 397 | "name is prefix only": {"lchat-operators-old:x:991:paul\n", false}, |
| 398 | } |
| 399 | for name, tc := range cases { |
| 400 | p := filepath.Join(t.TempDir(), "group") |
| 401 | if err := os.WriteFile(p, []byte(tc.content), 0o644); err != nil { |
| 402 | t.Fatal(err) |
| 403 | } |
| 404 | if got := operatorsExist(p); got != tc.want { |
| 405 | t.Errorf("%s: operatorsExist = %v, want %v", name, got, tc.want) |
| 406 | } |
| 407 | } |
| 408 | if operatorsExist(filepath.Join(t.TempDir(), "missing")) { |
| 409 | t.Error("missing group file should read as no operators") |
| 410 | } |
| 411 | } |
| 412 | |
| 413 | // TestNoRootDropIn guards the drop-in contract: the directive itself, and the |
| 414 | // filename ordering that makes it effective — sshd config is first-match-wins, |
| 415 | // so the file must sort before the distro's 50-*.conf (prohibit-password). |
| 416 | func TestNoRootDropIn(t *testing.T) { |
| 417 | if !strings.Contains(noRootConf, "PermitRootLogin no\n") { |
| 418 | t.Error("drop-in missing its PermitRootLogin no directive") |
| 419 | } |
| 420 | base := filepath.Base(noRootConfPath) |
| 421 | if base >= "50-" { |
| 422 | t.Errorf("drop-in %s sorts after 50-*.conf — it would lose first-match to the distro default", base) |
| 423 | } |
| 424 | if filepath.Dir(noRootConfPath) != "/etc/ssh/sshd_config.d" { |
| 425 | t.Errorf("drop-in in %s, want /etc/ssh/sshd_config.d", filepath.Dir(noRootConfPath)) |
| 426 | } |
| 427 | } |
| 428 | |
| 429 | // TestApplyNoRootSSH covers the converge flow with the exec seams stubbed: |
| 430 | // fresh write reloads sshd, an already-correct file is a silent no-op, a |
| 431 | // failed validation restores exactly the prior state (file absent or prior |
| 432 | // content) so sshd's next real restart can never hit config it would refuse, |
| 433 | // and a drop-in that fails the EFFECTIVE-config check errors on both the |
| 434 | // fresh and idempotent paths (the kepler 00-hardening.conf shape). |
| 435 | func TestApplyNoRootSSH(t *testing.T) { |
| 436 | oldCheck, oldSysctl, oldEff := sshdCheck, systemctl, sshdRootLogin |
| 437 | defer func() { sshdCheck, systemctl, sshdRootLogin = oldCheck, oldSysctl, oldEff }() |
| 438 | |
| 439 | var reloads []string |
| 440 | systemctl = func(args ...string) error { |
| 441 | reloads = append(reloads, strings.Join(args, " ")) |
| 442 | return nil |
| 443 | } |
| 444 | sshdCheck = func() error { return nil } |
| 445 | sshdRootLogin = func() (string, error) { return "no", nil } |
| 446 | |
| 447 | p := filepath.Join(t.TempDir(), "10-lchat-noroot.conf") |
| 448 | changed, err := applyNoRootSSH(p) |
| 449 | if err != nil || !changed { |
| 450 | t.Fatalf("fresh apply: changed=%v err=%v, want true, nil", changed, err) |
| 451 | } |
| 452 | if b, _ := os.ReadFile(p); string(b) != noRootConf { |
| 453 | t.Errorf("drop-in content = %q", b) |
| 454 | } |
| 455 | if len(reloads) != 1 || reloads[0] != "reload ssh" { |
| 456 | t.Errorf("reloads = %v, want [reload ssh]", reloads) |
| 457 | } |
| 458 | |
| 459 | // Second pass: already converged — no write, no reload. |
| 460 | reloads = nil |
| 461 | if changed, err = applyNoRootSSH(p); err != nil || changed { |
| 462 | t.Fatalf("idempotent apply: changed=%v err=%v, want false, nil", changed, err) |
| 463 | } |
| 464 | if len(reloads) != 0 { |
| 465 | t.Errorf("idempotent apply reloaded sshd: %v", reloads) |
| 466 | } |
| 467 | |
| 468 | // Validation failure over an existing (stale) file restores it verbatim. |
| 469 | stale := "PermitRootLogin prohibit-password\n" |
| 470 | if err := os.WriteFile(p, []byte(stale), 0o644); err != nil { |
| 471 | t.Fatal(err) |
| 472 | } |
| 473 | sshdCheck = func() error { return os.ErrInvalid } |
| 474 | if _, err = applyNoRootSSH(p); err == nil { |
| 475 | t.Fatal("apply with failing sshd -t should error") |
| 476 | } |
| 477 | if b, _ := os.ReadFile(p); string(b) != stale { |
| 478 | t.Errorf("failed validation left %q, want prior content restored", b) |
| 479 | } |
| 480 | |
| 481 | // Validation failure on a fresh box removes the file entirely. |
| 482 | p2 := filepath.Join(t.TempDir(), "10-lchat-noroot.conf") |
| 483 | if _, err = applyNoRootSSH(p2); err == nil { |
| 484 | t.Fatal("apply with failing sshd -t should error") |
| 485 | } |
| 486 | if _, statErr := os.Stat(p2); !os.IsNotExist(statErr) { |
| 487 | t.Errorf("failed validation left the drop-in behind (stat err %v)", statErr) |
| 488 | } |
| 489 | |
| 490 | // An earlier-sorting file overriding the drop-in (effective config stays |
| 491 | // prohibit-password): the fresh write must error — success here is the |
| 492 | // kepler bug — and the drop-in stays in place (inert, not harmful) for the |
| 493 | // re-run after the operator fixes the ordering. |
| 494 | sshdCheck = func() error { return nil } |
| 495 | sshdRootLogin = func() (string, error) { return "prohibit-password", nil } |
| 496 | p3 := filepath.Join(t.TempDir(), "10-lchat-noroot.conf") |
| 497 | if _, err = applyNoRootSSH(p3); err == nil || !strings.Contains(err.Error(), "first-match-wins") { |
| 498 | t.Fatalf("ineffective drop-in: err = %v, want first-match-wins explanation", err) |
| 499 | } |
| 500 | if b, _ := os.ReadFile(p3); string(b) != noRootConf { |
| 501 | t.Errorf("ineffective drop-in should stay in place, got %q", b) |
| 502 | } |
| 503 | // Same on the idempotent path: an already-correct file that isn't winning |
| 504 | // must not report success. |
| 505 | if _, err = applyNoRootSSH(p3); err == nil { |
| 506 | t.Fatal("idempotent apply over an ineffective drop-in should error") |
| 507 | } |
| 508 | } |
| 509 | |
| 510 | func TestCountAuthorizedKeys(t *testing.T) { |
| 511 | cases := []struct { |
| 512 | name string |
| 513 | in string |
| 514 | n int |
| 515 | ok bool |
| 516 | }{ |
| 517 | {"one ed25519", "ssh-ed25519 AAAAC3Nza james@mac\n", 1, true}, |
| 518 | {"two with comments and blanks", "# ci key\nssh-ed25519 AAAA a\n\nsk-ssh-ed25519@openssh.com BBBB b\n", 2, true}, |
| 519 | {"ecdsa", "ecdsa-sha2-nistp256 AAAA\n", 1, true}, |
| 520 | {"empty", "", 0, false}, |
| 521 | {"whitespace only", "\n \n", 0, false}, |
| 522 | {"junk line", "ssh-ed25519 AAAA ok\nnot a key\n", 0, false}, |
| 523 | {"missing key body", "ssh-ed25519\n", 0, false}, |
| 524 | } |
| 525 | for _, c := range cases { |
| 526 | n, err := countAuthorizedKeys([]byte(c.in)) |
| 527 | if (err == nil) != c.ok || n != c.n { |
| 528 | t.Errorf("%s: n=%d err=%v, want n=%d ok=%v", c.name, n, err, c.n, c.ok) |
| 529 | } |
| 530 | } |
| 531 | } |
| 532 | |
| 533 | // TestAddOperatorAccount drives the whole verb through the exec/user seams: |
| 534 | // the create path must run useradd, the converge path must not, and a bad |
| 535 | // keys pipe must fail BEFORE any account command runs — with root SSH off, a |
| 536 | // clobbered authorized_keys is a console/rescue trip. |
| 537 | func TestAddOperatorAccount(t *testing.T) { |
| 538 | oldRun, oldLookup := runAccountCmd, lookupAccount |
| 539 | defer func() { runAccountCmd, lookupAccount = oldRun, oldLookup }() |
| 540 | |
| 541 | home := t.TempDir() |
| 542 | exists := false |
| 543 | var calls []string |
| 544 | runAccountCmd = func(name string, args ...string) error { |
| 545 | calls = append(calls, name+" "+strings.Join(args, " ")) |
| 546 | if name == "useradd" { |
| 547 | exists = true |
| 548 | } |
| 549 | return nil |
| 550 | } |
| 551 | lookupAccount = func(string) (string, int, int, bool) { |
| 552 | return home, os.Getuid(), os.Getgid(), exists |
| 553 | } |
| 554 | |
| 555 | keys := "ssh-ed25519 AAAA james@mac\nssh-ed25519 BBBB james@laptop\n" |
| 556 | if err := addOperatorAccount("james", strings.NewReader(keys)); err != nil { |
| 557 | t.Fatalf("create: %v", err) |
| 558 | } |
| 559 | want := []string{"groupadd -f lchat-operators", "useradd -m -s /bin/bash james", |
| 560 | "usermod -aG lchat-operators,systemd-journal james"} |
| 561 | if strings.Join(calls, ";") != strings.Join(want, ";") { |
| 562 | t.Errorf("create calls = %v, want %v", calls, want) |
| 563 | } |
| 564 | ak := filepath.Join(home, ".ssh", "authorized_keys") |
| 565 | if b, err := os.ReadFile(ak); err != nil || string(b) != keys { |
| 566 | t.Errorf("authorized_keys = %q, %v", b, err) |
| 567 | } |
| 568 | for p, want := range map[string]os.FileMode{filepath.Join(home, ".ssh"): 0o700, ak: 0o600} { |
| 569 | if fi, err := os.Stat(p); err != nil || fi.Mode().Perm() != want { |
| 570 | t.Errorf("%s mode = %v, want %v", p, fi.Mode().Perm(), want) |
| 571 | } |
| 572 | } |
| 573 | |
| 574 | // Converge an existing account with a rotated key set: no useradd, and |
| 575 | // the file is REPLACED, not appended. |
| 576 | calls = nil |
| 577 | rotated := "ssh-ed25519 CCCC james@new\n" |
| 578 | if err := addOperatorAccount("james", strings.NewReader(rotated)); err != nil { |
| 579 | t.Fatalf("converge: %v", err) |
| 580 | } |
| 581 | for _, c := range calls { |
| 582 | if strings.HasPrefix(c, "useradd") { |
| 583 | t.Errorf("converge ran useradd: %v", calls) |
| 584 | } |
| 585 | } |
| 586 | if b, _ := os.ReadFile(ak); string(b) != rotated { |
| 587 | t.Errorf("rotated authorized_keys = %q, want replacement", b) |
| 588 | } |
| 589 | |
| 590 | // Bad pipes and bad names fail before any account command. |
| 591 | calls = nil |
| 592 | if err := addOperatorAccount("james", strings.NewReader("")); err == nil { |
| 593 | t.Error("empty stdin should error") |
| 594 | } |
| 595 | if err := addOperatorAccount("James Adam", strings.NewReader(rotated)); err == nil { |
| 596 | t.Error("invalid account name should error") |
| 597 | } |
| 598 | if len(calls) != 0 { |
| 599 | t.Errorf("failed runs still executed commands: %v", calls) |
| 600 | } |
| 601 | } |
| 602 | |