package scripts import ( "github.com/drs/gre-panel/internal/persist" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "testing" ) // The installer is 593 lines of shell with no way to drive it from a test: no // --dry-run, no --check, and running it for real would install a panel on the // machine running the suite. // // What can be checked without running it is its contract with everything // around it, by parsing it the way internal/persist already parses the unit it // embeds. These are the invariants where being wrong is invisible until an // operator hits them: an exit code the README promises that the script cannot // produce, or a stamp the release build silently drops. func readScript(t *testing.T, name string) string { t.Helper() body, err := os.ReadFile(filepath.Join(name)) if err != nil { t.Fatalf("reading %s: %v", name, err) } return strings.ReplaceAll(string(body), "\r\n", "\n") } // TestTheDocumentedExitCodesAreTheOnesTheInstallerCanProduce holds the two // halves of §3.4's requirement to prove every documented exit code. // // The README is the contract other tooling reads: a wrapper that treats 16 as // "checksum failed" is relying on it. So a code the README documents has to // exist in the script and be reachable, and a code the script can exit with has // to be documented — an undocumented one is a failure mode nobody can handle // deliberately. func TestTheDocumentedExitCodesAreTheOnesTheInstallerCanProduce(t *testing.T) { script := readScript(t, "install.sh") readme, err := os.ReadFile(filepath.Join("..", "README.md")) if err != nil { t.Fatalf("reading the README: %v", err) } // The constants the script declares, by value. declared := map[int]string{} for _, match := range regexp.MustCompile(`(?m)^readonly (EXIT_[A-Z_]+)=(\d+)`). FindAllStringSubmatch(script, -1) { code, convErr := strconv.Atoi(match[2]) if convErr != nil { t.Fatalf("exit constant %s has a non-numeric value %q", match[1], match[2]) } if existing, clash := declared[code]; clash { t.Errorf("%s and %s are both %d, so a caller cannot tell them apart", existing, match[1], code) } declared[code] = match[1] } if len(declared) < 5 { t.Fatalf("only %d exit constants were found; the pattern is not matching", len(declared)) } // The codes the README's table documents. documented := map[int]bool{} table := regexp.MustCompile(`(?m)^\|\s*(\d+)\s*\|`) section := string(readme) if at := strings.Index(section, "### Exit codes"); at >= 0 { section = section[at:] } for _, match := range table.FindAllStringSubmatch(section, -1) { code, _ := strconv.Atoi(match[1]) documented[code] = true } if len(documented) < 5 { t.Fatalf("only %d exit codes were found in the README", len(documented)) } var undocumented, unimplemented, unused []string for code, name := range declared { if !documented[code] { undocumented = append(undocumented, name+" = "+strconv.Itoa(code)) } // Declared and never used is a code the script cannot actually produce, // which is the same promise broken from the other end. if strings.Count(script, name) < 2 { unused = append(unused, name) } } for code := range documented { if _, ok := declared[code]; !ok { unimplemented = append(unimplemented, strconv.Itoa(code)) } } sort.Strings(undocumented) sort.Strings(unimplemented) sort.Strings(unused) if len(undocumented) > 0 { t.Errorf("the installer can exit with %d code(s) the README does not document, so nothing "+ "calling it can handle them deliberately:\n %s", len(undocumented), strings.Join(undocumented, "\n ")) } if len(unimplemented) > 0 { t.Errorf("the README documents %d exit code(s) the installer does not define:\n %s", len(unimplemented), strings.Join(unimplemented, "\n ")) } if len(unused) > 0 { t.Errorf("%d exit constant(s) are declared and never used, so the README promises a code "+ "the installer cannot produce:\n %s", len(unused), strings.Join(unused, "\n ")) } } // TestTheReleaseBuildAcceptsAStampFromTheEnvironment is the regression for the // change that let a release built from an exported tree identify itself. // // The documented way to deploy is to build a release and carry it to the // target, and a tree that arrived by `git archive` or scp has no .git to ask. // The stamp then fell back to "unknown" and the panel could no longer report // which commit it was running — the one thing the version banner exists for. // // Checked by parsing rather than by running, because running it builds a binary // for every architecture. func TestTheReleaseBuildAcceptsAStampFromTheEnvironment(t *testing.T) { script := readScript(t, "build-release.sh") for _, tc := range []struct{ variable, fallback string }{ {"GRE_PANEL_BUILD_COMMIT", "git rev-parse"}, {"GRE_PANEL_BUILD_DATE", "git show"}, } { // The override has to be consulted... assignment := regexp.MustCompile(`\$\{` + tc.variable + `:-`) if !assignment.MatchString(script) { t.Errorf("%s is not honoured, so a build from a tree with no .git cannot identify "+ "itself", tc.variable) continue } // ...and it has to be a default-if-unset, so a checkout still asks git. if !strings.Contains(script, tc.fallback) { t.Errorf("%s has no %s fallback, so a normal checkout would stop stamping itself", tc.variable, tc.fallback) } } // The stamp reaches the binary, or none of the above matters. for _, flag := range []string{"-X main.version=", "-X main.commit=", "-X main.buildDate="} { if !strings.Contains(script, flag) { t.Errorf("the link flags do not carry %s, so the value is computed and thrown away", flag) } } } // TestTheStaticCheckIsActuallyInvoked pins finding 11 at the integration point: // check-static.sh classifies correctly (see check_static_test.go), but only if // the release build still calls it. func TestTheStaticCheckIsActuallyInvoked(t *testing.T) { script := readScript(t, "build-release.sh") if !strings.Contains(script, "check-static.sh") { t.Fatal("build-release.sh no longer runs the static-linking check, so a release could " + "ship dynamically linked with nothing noticing") } // The old inline check is gone. It was inverted — `ldd | grep -qv "not a // dynamic executable"` is true for almost any output — and it treated a host // that could not read the binary as one that had read it and found it clean. if strings.Contains(script, `grep -qv "not a dynamic executable"`) { t.Error("the inverted inline ldd check is back in build-release.sh") } } // The unit's capability set is a safety invariant, not just hardening. // // The panel refuses to forward the port the live SSH daemon is listening on, // and it finds that port by reading which process holds the listening socket — // walking /proc//fd for the socket's inode. Reading another process's file // descriptors is a ptrace-mode operation: same-uid is not enough when the // target holds capabilities the reader does not, and sshd holds the full set // while the panel deliberately holds seven. Without CAP_SYS_PTRACE the walk is // denied for every process except the panel itself, the owner map comes back // empty, and SshPorts() reports that nothing is listening on SSH at all. // // Measured on both live hosts, in both sshd configurations: // // shipping unit -> SshPorts: [] // shipping unit + SYS_PTRACE -> SshPorts: [22] // shipping unit + DAC_READ_SEARCH -> SshPorts: [] (not sufficient) // // The invariant still held, because an unidentified daemon falls back to // protecting port 22 as a precaution. But the fallback protects the // conventional port, not the live one: an installation that had moved SSH to // 2222 would have been protected on 22 and left forwardable on 2222, which is // the exact failure the socket-activation handling was written to prevent. // That handling could never run, because there was no candidate to choose // between. // // This pins the capability so a later hardening pass cannot quietly remove it // and take the protection with it. func TestTheUnitCanIdentifyTheProcessHoldingAPort(t *testing.T) { script := readScript(t, "install.sh") for _, directive := range []string{"AmbientCapabilities", "CapabilityBoundingSet"} { line := directiveLine(t, script, directive) if !strings.Contains(line, "CAP_SYS_PTRACE") { t.Errorf("%s does not grant CAP_SYS_PTRACE:\n %s\n"+ "Without it the panel cannot read another process's /proc//fd, so it "+ "cannot tell which port the live SSH daemon holds and protects the "+ "conventional port instead of the real one.", directive, line) } } } // directiveLine returns the single unit directive with this name. func directiveLine(t *testing.T, script, name string) string { t.Helper() pattern := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(name) + `=.*$`) found := pattern.FindAllString(script, -1) if len(found) == 0 { t.Fatalf("the installer declares no %s at all", name) } if len(found) > 1 { t.Fatalf("the installer declares %s %d times; this test would check the wrong one", name, len(found)) } return found[0] } // --purge-tunnels has to remove the tunnels the panel actually wrote. // // It globbed /etc/systemd/system/gre-panel-tunnel-*.service, and the panel has // never written a file by that name: persist.UnitName is the interface name // plus ".service", so the units are gre-a-1.service and the like. The glob // matched nothing, the loop body never ran, and the flag removed no tunnels at // all — while printing "Removing panel-managed tunnels" and reporting // "purged_tunnels": true. // // Observed on a live host: a panel-managed tunnel and its unit were both still // present after --purge-tunnels reported success. // // Selecting by name cannot work anyway, because a legacy unit adopted from a // previous setup is named for its interface too and must NOT be removed. The // only safe discriminator is the ownership marker the panel writes into every // file it generates, which is what adoption already uses to decide whether a // unit is the panel's to overwrite. func TestPurgeRemovesTheUnitsThePanelActuallyWrites(t *testing.T) { script := readScript(t, "install.sh") purge := sectionBetween(script, "if [[ $PURGE_TUNNELS -eq 1 ]]; then", "rm -f \"$UNIT_PATH\"") if purge == "" { t.Fatal("could not find the --purge-tunnels block; this test would pass vacuously") } // A prefix the panel never generates cannot select anything it wrote. if strings.Contains(purge, "gre-panel-tunnel-") { t.Errorf("the purge block still selects gre-panel-tunnel-*, which persist.UnitName never "+ "produces:\n%s", indent(purge)) } // The ownership marker is the discriminator. The script may hold it in a // variable, so what matters is that the block consults it and that the // string the script defines is byte-for-byte the one the panel writes — // a copy that drifts would silently stop matching anything. const markerVar = "PANEL_FILE_MARKER" if !strings.Contains(purge, markerVar) && !strings.Contains(purge, persist.OwnershipMarker) { t.Errorf("the purge block consults neither %s nor the marker literal, so it cannot tell a "+ "unit the panel wrote from a legacy one adopted from a previous setup:\n%s", markerVar, indent(purge)) } declared := regexp.MustCompile(`(?m)^readonly ` + markerVar + `="([^"]*)"`).FindStringSubmatch(script) if declared == nil { t.Fatalf("the script does not declare %s, so nothing pins it to the panel's own marker", markerVar) } if declared[1] != persist.OwnershipMarker { t.Errorf("the installer looks for %q but the panel writes %q; a purge would match nothing", declared[1], persist.OwnershipMarker) } } // sectionBetween returns the text between two markers, exclusive of the end. func sectionBetween(text, start, end string) string { from := strings.Index(text, start) if from < 0 { return "" } rest := text[from:] to := strings.Index(rest, end) if to < 0 { return rest } return rest[:to] } func indent(s string) string { var b strings.Builder for _, line := range strings.Split(strings.TrimSpace(s), "\n") { b.WriteString(" " + line + "\n") } return b.String() }