The tree adds the installer, service and test paths admitted by this source commit. Nothing else changes about what may cross. Source-Sha: 3e4a3aa4ce092e9e3f51bc78daf3ddc14d9638f8 Policy-Sha: 3e4a3aa4ce092e9e3f51bc78daf3ddc14d9638f8 Tree-Digest: 89d6fce3828a0e0de0c8eeb41ee6750f4462217e2a04b5190297134d7f4a50c3
328 lines
11 KiB
Go
328 lines
11 KiB
Go
package services
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"text/template"
|
|
)
|
|
|
|
// TemplateRenderData is the union of all fields across the three install
|
|
// template data structs. Template execution fails on unknown variables, so
|
|
// this test data exists for the sole purpose of verifying every {{.Field}}
|
|
// in the template resolves.
|
|
type TemplateRenderData struct {
|
|
AgentID string
|
|
BinaryURL string
|
|
ConfigURL string
|
|
Platform string
|
|
Architecture string
|
|
Version string
|
|
ServerURL string
|
|
RegistrationToken string
|
|
AgentUser string
|
|
AgentHome string
|
|
ConfigDir string
|
|
LogDir string
|
|
AgentConfigDir string
|
|
AgentLogDir string
|
|
ServerKeyDir string
|
|
ServerPublicKey string
|
|
}
|
|
|
|
func TestInstallTemplateRenders(t *testing.T) {
|
|
data := TemplateRenderData{
|
|
AgentID: "00000000-0000-0000-0000-000000000000",
|
|
BinaryURL: "https://example.com/redflag-agent",
|
|
ConfigURL: "https://example.com/config",
|
|
Platform: "linux",
|
|
Architecture: "amd64",
|
|
Version: "0.2.9.0",
|
|
ServerURL: "https://example.com",
|
|
RegistrationToken: "rt_test",
|
|
AgentUser: "redflag-agent",
|
|
AgentHome: "/var/lib/redflag/agent",
|
|
ConfigDir: "/etc/redflag",
|
|
LogDir: "/var/log/redflag",
|
|
AgentConfigDir: "/etc/redflag/agent",
|
|
AgentLogDir: "/var/log/redflag/agent",
|
|
ServerKeyDir: "/etc/redflag/server",
|
|
ServerPublicKey: "abcdef0123456789",
|
|
}
|
|
|
|
scripts := []string{
|
|
"templates/install/scripts/linux.sh.tmpl",
|
|
"templates/install/scripts/windows.ps1.tmpl",
|
|
}
|
|
|
|
for _, name := range scripts {
|
|
t.Run(filepath.Base(name), func(t *testing.T) {
|
|
tmpl, err := template.ParseFS(installScriptTemplates, name)
|
|
if err != nil {
|
|
t.Fatalf("parse template %s: %v", name, err)
|
|
}
|
|
|
|
var buf strings.Builder
|
|
if err := tmpl.Execute(&buf, data); err != nil {
|
|
t.Fatalf("render template %s: %v", name, err)
|
|
}
|
|
|
|
output := buf.String()
|
|
if len(output) < 100 {
|
|
t.Fatalf("rendered template %s too short (%d bytes): likely empty or error", name, len(output))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWindowsInstallerVerifiesBeforeMutation(t *testing.T) {
|
|
raw, err := installScriptTemplates.ReadFile("templates/install/scripts/windows.ps1.tmpl")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
source := string(raw)
|
|
|
|
// Comments in the template quote the very patterns asserted below, so
|
|
// occurrence counts are taken against the code alone.
|
|
var codeOnly strings.Builder
|
|
for _, line := range strings.Split(source, "\n") {
|
|
if !strings.HasPrefix(strings.TrimSpace(line), "#") {
|
|
codeOnly.WriteString(line)
|
|
codeOnly.WriteString("\n")
|
|
}
|
|
}
|
|
code := codeOnly.String()
|
|
|
|
// Anchors ignore leading indentation: the block below the staging try is
|
|
// indented, and control-flow order is what this test is about, not columns.
|
|
at := func(pattern string) int {
|
|
loc := regexp.MustCompile(`(?m)^[ \t]*` + pattern).FindStringIndex(source)
|
|
if loc == nil {
|
|
return -1
|
|
}
|
|
return loc[0]
|
|
}
|
|
ordered := []struct {
|
|
name string
|
|
pattern string
|
|
}{
|
|
{"verifier preflight", `Assert-Ed25519Verifier\r?$`},
|
|
{"manifest signature", `\$ok = Test-Ed25519Signature`},
|
|
{"binary signature", `\$verified = Test-Ed25519Signature`},
|
|
{"config ACL repair", `Repair-AgentConfigAccessIfNeeded -ConfigFilePath`},
|
|
{"service stop", `Stop-Service -Name`},
|
|
{"binary replacement", `Move-Item -Path \$TmpBinary`},
|
|
}
|
|
prev := -1
|
|
for _, step := range ordered {
|
|
got := at(step.pattern)
|
|
if got < 0 {
|
|
t.Fatalf("missing installer step %q (pattern %s)", step.name, step.pattern)
|
|
}
|
|
if got <= prev {
|
|
t.Fatalf("installer step %q runs too early: verification must precede config, service, and binary changes", step.name)
|
|
}
|
|
prev = got
|
|
}
|
|
|
|
// Fail-open verifier paths. The .NET Ed25519 probe used to return $null
|
|
// when no verifier existed and the installer carried on with a warning.
|
|
for _, forbidden := range []string{"return $null", "signature not checked", "best-effort (BouncyCastle)", "$null -eq $verified", "$null -eq $ok"} {
|
|
if strings.Contains(source, forbidden) {
|
|
t.Errorf("Windows installer contains fail-open verifier path: %s", forbidden)
|
|
}
|
|
}
|
|
|
|
// $ErrorActionPreference is Stop, so a bare Write-Error is terminating: it
|
|
// drops every diagnostic line after the first, skips the exit below it, and
|
|
// inside the desktop try/catch it was caught and downgraded to a skip
|
|
// warning that let the installer exit 0 on a manifest hash mismatch.
|
|
// Every refusal goes through Stop-Install instead.
|
|
if n := strings.Count(code, "Write-Error"); n != 1 {
|
|
t.Errorf("expected exactly one Write-Error (inside Stop-Install), found %d; refusals must use Stop-Install", n)
|
|
}
|
|
if !strings.Contains(code, "Write-Error $Line -ErrorAction Continue") {
|
|
t.Error("Stop-Install must report non-terminatingly so every diagnostic line reaches the operator")
|
|
}
|
|
if !regexp.MustCompile(`(?m)^\s*exit 1\s*$`).MatchString(source[strings.Index(source, "function Stop-Install"):]) {
|
|
t.Error("Stop-Install must exit deterministically")
|
|
}
|
|
|
|
// Cleanup must not replace the failure that brought us here.
|
|
if strings.Contains(code, "Remove-Item -LiteralPath $StagingDir") ||
|
|
strings.Contains(code, "Remove-Item -LiteralPath $Scratch") {
|
|
t.Error("staging cleanup must go through Remove-InstallerScratchDirectory so it cannot mask the primary failure")
|
|
}
|
|
if n := strings.Count(code, "Remove-InstallerScratchDirectory -Path"); n != 3 {
|
|
t.Errorf("expected every scratch removal to use the non-masking helper, found %d", n)
|
|
}
|
|
// Downloads live inside the staging directory the finally already removes;
|
|
// a redundant Remove-Item there can throw and mask a tampering refusal.
|
|
for _, dead := range []string{"Remove-Item $TmpBinary", "Remove-Item $TmpDesktop"} {
|
|
if strings.Contains(code, dead) {
|
|
t.Errorf("redundant cleanup %q can throw and mask the refusal it follows", dead)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFreshInstallConfigKeys verifies the install template's default JSON
|
|
// includes the config keys the agent expects at minimum. This catches drift
|
|
// where a new config struct field gets added but the template never writes it
|
|
// for fresh installs.
|
|
//
|
|
// THIS TEST IS NOT EXHAUSTIVE. It maintains a canonical list of keys that must
|
|
// appear in the fresh-install config template. Add new keys here when they are
|
|
// added to the agent Config struct and the template should set them.
|
|
func TestFreshInstallConfigKeys(t *testing.T) {
|
|
// Read and parse the linux.sh.tmpl to extract its default JSON block.
|
|
raw, err := installScriptTemplates.ReadFile("templates/install/scripts/linux.sh.tmpl")
|
|
if err != nil {
|
|
t.Fatalf("read template: %v", err)
|
|
}
|
|
source := string(raw)
|
|
|
|
// Extract the default JSON between the heredoc markers.
|
|
// The template writes: sudo tee "..." > /dev/null <<EOF
|
|
// Find the heredoc marker line, then the first `{` on the line after it.
|
|
heredoc := `sudo tee "${AGENT_CONFIG_DIR}/config.json" > /dev/null <<EOF`
|
|
heredocIdx := strings.Index(source, heredoc)
|
|
if heredocIdx == -1 {
|
|
t.Fatal("default JSON heredoc marker not found in install template")
|
|
}
|
|
// Skip past the <<EOF marker and the newline that follows.
|
|
afterHeredoc := heredocIdx + len(heredoc)
|
|
jsonStart := strings.Index(source[afterHeredoc:], "\n{")
|
|
if jsonStart == -1 {
|
|
t.Fatal("JSON start (\\n{) not found after heredoc marker")
|
|
}
|
|
jsonBlockStart := afterHeredoc + jsonStart + 1 // skip the newline
|
|
jsonEnd := strings.Index(source[jsonBlockStart:], "\nEOF")
|
|
if jsonEnd == -1 {
|
|
t.Fatal("JSON end (\\nEOF) not found after heredoc")
|
|
}
|
|
jsonBlock := source[jsonBlockStart : jsonBlockStart+jsonEnd]
|
|
|
|
// Parse the JSON block and collect top-level keys.
|
|
var parsed map[string]json.RawMessage
|
|
if err := json.Unmarshal([]byte(jsonBlock), &parsed); err != nil {
|
|
t.Fatalf("parse default JSON: %v\nblock:\n%s", err, jsonBlock)
|
|
}
|
|
|
|
// Canonical required keys — every Config struct field with a json tag
|
|
// that MUST appear in the fresh-install template. Keys with "omitempty"
|
|
// or that default to zero-value-safe are optional and not listed here.
|
|
required := []string{
|
|
"version",
|
|
"agent_version",
|
|
"agent_id",
|
|
"token",
|
|
"refresh_token",
|
|
"registration_token",
|
|
"check_in_interval",
|
|
"server_url",
|
|
"network",
|
|
"proxy",
|
|
"tls",
|
|
"logging",
|
|
"subsystems",
|
|
}
|
|
// "machine_id" in the template JSON is a stale key not present in the
|
|
// Config struct — the agent reads machine identity from hostname/TOFU.
|
|
// Not a required key; retained in the template for backward compat with
|
|
// config files that carry it.
|
|
|
|
for _, key := range required {
|
|
if _, exists := parsed[key]; !exists {
|
|
t.Errorf("required config key %q missing from fresh-install template JSON", key)
|
|
}
|
|
}
|
|
|
|
// Optional keys — nice-to-have in the template but not required for
|
|
// correct operation (zero values are handled by the agent).
|
|
recommended := []string{
|
|
"desktop",
|
|
"polling",
|
|
"command_signing",
|
|
"security_logging",
|
|
"security",
|
|
}
|
|
|
|
for _, key := range recommended {
|
|
if _, exists := parsed[key]; !exists {
|
|
t.Logf("[INFO] recommended config key %q not in template — fresh installs will use defaults", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestInstallTemplateScriptletSyntax performs a basic syntax sanity check on the
|
|
// shell script portion of the install template — enough to catch common errors
|
|
// like unterminated heredocs, unbalanced braces, or missing shell tokens.
|
|
func TestInstallTemplateScriptletSyntax(t *testing.T) {
|
|
|
|
// Render with test data first to substitute template variables.
|
|
data := TemplateRenderData{
|
|
AgentID: "00000000-0000-0000-0000-000000000000",
|
|
BinaryURL: "https://example.com/binary",
|
|
ConfigURL: "https://example.com/config",
|
|
Platform: "linux",
|
|
Architecture: "amd64",
|
|
Version: "0.2.9.0",
|
|
ServerURL: "https://example.com",
|
|
RegistrationToken: "rt_test",
|
|
AgentUser: "redflag-agent",
|
|
AgentHome: "/var/lib/redflag/agent",
|
|
ConfigDir: "/etc/redflag",
|
|
LogDir: "/var/log/redflag",
|
|
AgentConfigDir: "/etc/redflag/agent",
|
|
AgentLogDir: "/var/log/redflag/agent",
|
|
ServerKeyDir: "/etc/redflag/server",
|
|
ServerPublicKey: "abcdef0123456789",
|
|
}
|
|
|
|
tmpl, err := template.ParseFS(installScriptTemplates, "templates/install/scripts/linux.sh.tmpl")
|
|
if err != nil {
|
|
t.Fatalf("parse template: %v", err)
|
|
}
|
|
var buf strings.Builder
|
|
if err := tmpl.Execute(&buf, data); err != nil {
|
|
t.Fatalf("render template: %v", err)
|
|
}
|
|
|
|
rendered := buf.String()
|
|
|
|
// Check major sections are present.
|
|
sections := []string{
|
|
"#!/bin/bash",
|
|
"set -e",
|
|
"systemctl",
|
|
"sudoers",
|
|
"config.json",
|
|
"redflag-helper",
|
|
"redflag-desktop",
|
|
}
|
|
for _, s := range sections {
|
|
if !strings.Contains(rendered, s) {
|
|
t.Errorf("rendered template missing expected section: %q", s)
|
|
}
|
|
}
|
|
|
|
// Rough heredoc balance check — count open/close markers.
|
|
heredocOpens := strings.Count(rendered, "<<EOF")
|
|
heredocCloses := strings.Count(rendered, "EOF")
|
|
if heredocCloses < heredocOpens {
|
|
t.Errorf("possible unterminated heredoc: %d <<EOF markers but only %d EOF terminators", heredocOpens, heredocCloses)
|
|
}
|
|
}
|
|
|
|
// TestMain ensures the working directory is the server package root
|
|
// so the embedded templates resolve correctly.
|
|
func TestMain(m *testing.M) {
|
|
// Allow override for test runner flexibility.
|
|
if d := os.Getenv("REDFLAG_SERVER_DIR"); d != "" {
|
|
os.Chdir(d)
|
|
}
|
|
os.Exit(m.Run())
|
|
}
|