Bind action runner to enrolled agent identity

This commit is contained in:
Richard Courtman 2026-08-30 18:49:12 +01:00
parent 6ee3b7e24e
commit 77afff4f60
15 changed files with 254 additions and 25 deletions

View file

@ -963,6 +963,7 @@ read_action_runner_env_value() {
revoke_action_runner_credential() {
local runner_url=""
local runner_hostname=""
local runner_agent_id_direct=""
local runner_agent_id_file=""
local runner_agent_id=""
local runner_token_file=""
@ -975,17 +976,22 @@ revoke_action_runner_credential() {
runner_url=$(read_action_runner_env_value "PULSE_URL" || true)
runner_hostname=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_HOSTNAME" || true)
runner_agent_id_direct=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID" || true)
runner_agent_id_file=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID_FILE" || true)
runner_token_file=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_TOKEN_FILE" || true)
[[ "$runner_url" =~ ^https?://[^[:space:]]+$ && -n "$runner_hostname" ]] || return 1
[[ "$runner_agent_id_file" == /* && "$runner_agent_id_file" != *'/../'* &&
-f "$runner_agent_id_file" && ! -L "$runner_agent_id_file" ]] || return 1
[[ "$runner_token_file" == /* && "$runner_token_file" != *'/../'* &&
-f "$runner_token_file" && ! -L "$runner_token_file" ]] || return 1
runner_agent_id=$(head -1 "$runner_agent_id_file" 2>/dev/null || true)
(( ${#runner_agent_id} >= 1 && ${#runner_agent_id} <= 256 &&
if [[ -n "$runner_agent_id_direct" ]]; then
runner_agent_id="$runner_agent_id_direct"
else
[[ "$runner_agent_id_file" == /* && "$runner_agent_id_file" != *'/../'* &&
-f "$runner_agent_id_file" && ! -L "$runner_agent_id_file" ]] || return 1
runner_agent_id=$(head -1 "$runner_agent_id_file" 2>/dev/null || true)
fi
(( ${#runner_agent_id} >= 1 && ${#runner_agent_id} <= 128 &&
${#runner_hostname} >= 1 && ${#runner_hostname} <= 253 )) || return 1
[[ "$runner_agent_id" =~ ^[A-Za-z0-9._:-]+$ &&
[[ "$runner_agent_id" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]*$ &&
"$runner_hostname" =~ ^[A-Za-z0-9._:-]+$ ]] || return 1
token_mode=$(stat -c '%a' "$runner_token_file" 2>/dev/null || true)
token_size=$(wc -c < "$runner_token_file" 2>/dev/null | tr -d ' ' || true)
@ -1368,6 +1374,11 @@ write_action_runner_config() {
write_action_runner_env_value "PULSE_AGENT_RUNNER_STATE_DIR" "$ACTION_RUNNER_STATE_DIR"
write_action_runner_env_value "PULSE_AGENT_RUNNER_HEALTH_FILE" "$ACTION_RUNNER_HEALTH_FILE"
write_action_runner_env_value "PULSE_AGENT_RUNNER_ACTIVATION_NONCE" "$ACTION_RUNNER_ACTIVATION_NONCE"
if [[ -n "$AGENT_ID" ]]; then
[[ ${#AGENT_ID} -le 128 && "$AGENT_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]*$ ]] ||
fail "Action runner requires a valid canonical agent identity" "$EXIT_MISSING_ARGS"
write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID" "$AGENT_ID"
fi
write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID_FILE" "${STATE_DIR%/}/agent-id"
write_action_runner_env_value "PULSE_AGENT_RUNNER_HOSTNAME" "$runner_hostname"
if [[ -n "$SERVER_FINGERPRINT" ]]; then
@ -1420,6 +1431,15 @@ write_action_runner_env_value() {
printf '%s="%s"\n' "$key" "$value" >> "$ACTION_RUNNER_ENV_FILE"
}
resolve_action_runner_agent_id() {
local agent_id="${AGENT_ID:-}"
if [[ -z "$agent_id" && -s "${STATE_DIR%/}/agent-id" && ! -L "${STATE_DIR%/}/agent-id" ]]; then
agent_id=$(head -1 "${STATE_DIR%/}/agent-id" 2>/dev/null || true)
fi
[[ ${#agent_id} -ge 1 && ${#agent_id} -le 128 && "$agent_id" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]*$ ]] || return 1
printf '%s\n' "$agent_id"
}
provision_action_runner() {
local backup_suffix=".pulse-install-backup.$$"
local path=""
@ -1473,10 +1493,8 @@ provision_action_runner() {
if [[ "$apply_succeeded" == "true" ]]; then
local attempt=0
while [[ "$attempt" -lt 30 ]]; do
local expected_agent_id="${AGENT_ID}"
if [[ -s "${STATE_DIR%/}/agent-id" ]]; then
expected_agent_id=$(head -1 "${STATE_DIR%/}/agent-id" 2>/dev/null || true)
fi
local expected_agent_id=""
expected_agent_id=$(resolve_action_runner_agent_id || true)
if systemctl is-active --quiet "${ACTION_RUNNER_NAME}.service" &&
action_runner_health_matches_activation "$expected_agent_id" "$activation_nonce"; then
runner_active="true"

View file

@ -5952,6 +5952,7 @@ func TestInstallSHActionRunnerIsSeparateOptInLifecycle(t *testing.T) {
`The action runner must use a separate credential from the collector token`,
`Preserving existing separately enabled action-runner profile`,
`write_action_runner_env_value "PULSE_AGENT_RUNNER_TOKEN_FILE" "$ACTION_RUNNER_TOKEN_FILE"`,
`write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID" "$AGENT_ID"`,
`write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID_FILE" "${STATE_DIR%/}/agent-id"`,
`write_action_runner_env_value "PULSE_AGENT_RUNNER_HEALTH_FILE" "$ACTION_RUNNER_HEALTH_FILE"`,
`write_action_runner_env_value "PULSE_AGENT_RUNNER_ACTIVATION_NONCE" "$ACTION_RUNNER_ACTIVATION_NONCE"`,
@ -5979,6 +5980,66 @@ func TestInstallSHActionRunnerIsSeparateOptInLifecycle(t *testing.T) {
}
}
func TestInstallSHActionRunnerConfigBindsKnownAgentIDImmediately(t *testing.T) {
root := t.TempDir()
envFile := filepath.Join(root, "runner.env")
script := `
set -euo pipefail
ACTION_RUNNER_CONFIG_DIR="` + filepath.Join(root, "config") + `"
ACTION_RUNNER_STATE_DIR="` + filepath.Join(root, "runner-state") + `"
ACTION_RUNNER_TOKEN_FILE="` + filepath.Join(root, "config", "token") + `"
ACTION_RUNNER_ENV_FILE="` + envFile + `"
ACTION_RUNNER_HEALTH_FILE="` + filepath.Join(root, "runner-state", "health.json") + `"
ACTION_RUNNER_ACTIVATION_NONCE="` + strings.Repeat("a", 64) + `"
STATE_DIR="` + filepath.Join(root, "collector-state") + `"
HOSTNAME_OVERRIDE="secure-runtime.lab"
AGENT_ID="agent-bound-before-collector-report"
ACTION_TOKEN="runner-secret"
PULSE_URL="https://pulse.example"
SERVER_FINGERPRINT=""
CURL_CA_BUNDLE=""
INSECURE="false"
EXIT_GENERAL=1
EXIT_MISSING_ARGS=2
fail() { printf '%s\n' "$1" >&2; exit "$2"; }
install() { local destination="${!#}"; mkdir -p "$destination"; chmod 0700 "$destination"; }
chown() { return 0; }
` + extractInstallShellFunction(t, "write_action_runner_env_value") + `
` + extractInstallShellFunction(t, "write_action_runner_config") + `
write_action_runner_config
`
if out, err := exec.Command("bash", "-c", script).CombinedOutput(); err != nil {
t.Fatalf("write action-runner config: %v\n%s", err, out)
}
content, err := os.ReadFile(envFile)
if err != nil {
t.Fatal(err)
}
want := `PULSE_AGENT_RUNNER_AGENT_ID="agent-bound-before-collector-report"`
if !strings.Contains(string(content), want) {
t.Fatalf("action-runner environment missing immediate identity binding %q:\n%s", want, content)
}
}
func TestInstallSHActionRunnerDirectIdentityOverridesStaleCollectorFile(t *testing.T) {
stateDir := t.TempDir()
mustWrite(t, filepath.Join(stateDir, "agent-id"), "stale-agent-id\n")
script := `
set -euo pipefail
STATE_DIR="` + stateDir + `"
AGENT_ID="current-agent-id"
` + extractInstallShellFunction(t, "resolve_action_runner_agent_id") + `
resolve_action_runner_agent_id
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("resolve action-runner identity: %v\n%s", err, out)
}
if got := strings.TrimSpace(string(out)); got != "current-agent-id" {
t.Fatalf("resolved action-runner identity = %q, want direct binding", got)
}
}
func TestInstallSHActionRunnerReadinessRequiresCurrentActivationNonce(t *testing.T) {
root := t.TempDir()
healthPath := filepath.Join(root, "health.json")
@ -6055,16 +6116,15 @@ func TestInstallSHActionRunnerSelfRevokeUsesPrivateCredential(t *testing.T) {
root := t.TempDir()
envFile := filepath.Join(root, "runner.env")
tokenFile := filepath.Join(root, "token")
agentIDFile := filepath.Join(root, "agent-id")
mustWrite(t, tokenFile, token+"\n")
mustWrite(t, agentIDFile, "agent-secure-runtime\n")
if err := os.Chmod(tokenFile, 0o600); err != nil {
t.Fatal(err)
}
mustWrite(t, envFile, strings.Join([]string{
`PULSE_URL="` + server.URL + `"`,
`PULSE_AGENT_RUNNER_HOSTNAME="secure-runtime.lab"`,
`PULSE_AGENT_RUNNER_AGENT_ID_FILE="` + agentIDFile + `"`,
`PULSE_AGENT_RUNNER_AGENT_ID="agent-secure-runtime"`,
`PULSE_AGENT_RUNNER_AGENT_ID_FILE="` + filepath.Join(root, "missing-agent-id") + `"`,
`PULSE_AGENT_RUNNER_TOKEN_FILE="` + tokenFile + `"`,
}, "\n")+"\n")

View file

@ -255,6 +255,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
self.assertEqual(
policy_ids,
[
"service-discovery-assurance-runtime",
"maintenance-verification-runtime",
"availability-certificate-runtime",
"discovery-provider-runtime",