Harden demo SSH setup for IP targets

This commit is contained in:
rcourtman 2026-07-09 17:06:52 +01:00
parent 76ced45c3a
commit 2be167331d
6 changed files with 144 additions and 85 deletions

65
.github/scripts/setup-demo-ssh.sh vendored Normal file
View file

@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
: "${DEMO_SERVER_HOST:?DEMO_SERVER_HOST is required}"
: "${DEMO_SERVER_SSH_KEY:?DEMO_SERVER_SSH_KEY is required}"
SSH_DIR="${HOME}/.ssh"
IDENTITY_FILE="${DEMO_SSH_IDENTITY_FILE:-${SSH_DIR}/id_ed25519}"
KNOWN_HOSTS_FILE="${DEMO_SSH_KNOWN_HOSTS_FILE:-${SSH_DIR}/known_hosts}"
is_ip_literal() {
python3 - "$1" <<'PY'
import ipaddress
import sys
try:
ipaddress.ip_address(sys.argv[1])
except ValueError:
sys.exit(1)
sys.exit(0)
PY
}
mkdir -p "$SSH_DIR"
chmod 700 "$SSH_DIR"
printf '%s\n' "$DEMO_SERVER_SSH_KEY" > "$IDENTITY_FILE"
chmod 600 "$IDENTITY_FILE"
: > "$KNOWN_HOSTS_FILE"
keyscan_output="$(mktemp)"
keyscan_error="$(mktemp)"
trap 'rm -f "$keyscan_output" "$keyscan_error"' EXIT
host_needs_dns=true
if is_ip_literal "$DEMO_SERVER_HOST"; then
host_needs_dns=false
echo "Demo SSH host is an IP literal; skipping DNS resolution wait."
fi
MAX_SSH_SETUP_ATTEMPTS=18
for attempt in $(seq 1 "$MAX_SSH_SETUP_ATTEMPTS"); do
if [ "$host_needs_dns" = "true" ] && ! getent hosts "$DEMO_SERVER_HOST" >/dev/null 2>&1; then
echo "Demo SSH host is not resolvable yet on attempt ${attempt}/${MAX_SSH_SETUP_ATTEMPTS}."
elif ssh-keyscan -T 10 -H "$DEMO_SERVER_HOST" > "$keyscan_output" 2>"$keyscan_error" && [ -s "$keyscan_output" ]; then
cat "$keyscan_output" >> "$KNOWN_HOSTS_FILE"
chmod 600 "$KNOWN_HOSTS_FILE"
echo "Demo SSH host key captured."
exit 0
else
echo "ssh-keyscan did not return demo host keys on attempt ${attempt}/${MAX_SSH_SETUP_ATTEMPTS}."
fi
if [ "$attempt" -lt "$MAX_SSH_SETUP_ATTEMPTS" ]; then
sleep 10
fi
done
echo "::error::Timed out waiting for the demo SSH host to become reachable and return host keys after Tailscale setup."
if command -v tailscale >/dev/null 2>&1; then
tailscale status --peers=false || true
fi
if [ -s "$keyscan_error" ]; then
sed 's/^/ssh-keyscan: /' "$keyscan_error" || true
fi
exit 1

View file

@ -143,41 +143,7 @@ jobs:
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_SSH_KEY: ${{ secrets.DEMO_SERVER_SSH_KEY }}
run: |
set -euo pipefail
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "$DEMO_SERVER_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
: > ~/.ssh/known_hosts
keyscan_output="$(mktemp)"
keyscan_error="$(mktemp)"
MAX_SSH_SETUP_ATTEMPTS=18
for attempt in $(seq 1 "$MAX_SSH_SETUP_ATTEMPTS"); do
if getent hosts "$DEMO_SERVER_HOST" >/dev/null 2>&1; then
if ssh-keyscan -T 10 -H "$DEMO_SERVER_HOST" > "$keyscan_output" 2>"$keyscan_error" && [ -s "$keyscan_output" ]; then
cat "$keyscan_output" >> ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
echo "Demo SSH host key captured."
exit 0
fi
echo "ssh-keyscan did not return demo host keys on attempt ${attempt}/${MAX_SSH_SETUP_ATTEMPTS}."
else
echo "Demo SSH host is not resolvable yet on attempt ${attempt}/${MAX_SSH_SETUP_ATTEMPTS}."
fi
if [ "$attempt" -lt "$MAX_SSH_SETUP_ATTEMPTS" ]; then
sleep 10
fi
done
echo "::error::Timed out waiting for the demo SSH host to resolve and return host keys after Tailscale setup."
tailscale status --peers=false || true
if [ -s "$keyscan_error" ]; then
sed 's/^/ssh-keyscan: /' "$keyscan_error" || true
fi
exit 1
run: bash .github/scripts/setup-demo-ssh.sh
- name: Verify target host identity
env:

View file

@ -250,41 +250,7 @@ jobs:
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_SSH_KEY: ${{ secrets.DEMO_SERVER_SSH_KEY }}
run: |
set -euo pipefail
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "$DEMO_SERVER_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
: > ~/.ssh/known_hosts
keyscan_output="$(mktemp)"
keyscan_error="$(mktemp)"
MAX_SSH_SETUP_ATTEMPTS=18
for attempt in $(seq 1 "$MAX_SSH_SETUP_ATTEMPTS"); do
if getent hosts "$DEMO_SERVER_HOST" >/dev/null 2>&1; then
if ssh-keyscan -T 10 -H "$DEMO_SERVER_HOST" > "$keyscan_output" 2>"$keyscan_error" && [ -s "$keyscan_output" ]; then
cat "$keyscan_output" >> ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
echo "Demo SSH host key captured."
exit 0
fi
echo "ssh-keyscan did not return demo host keys on attempt ${attempt}/${MAX_SSH_SETUP_ATTEMPTS}."
else
echo "Demo SSH host is not resolvable yet on attempt ${attempt}/${MAX_SSH_SETUP_ATTEMPTS}."
fi
if [ "$attempt" -lt "$MAX_SSH_SETUP_ATTEMPTS" ]; then
sleep 10
fi
done
echo "::error::Timed out waiting for the demo SSH host to resolve and return host keys after Tailscale setup."
tailscale status --peers=false || true
if [ -s "$keyscan_error" ]; then
sed 's/^/ssh-keyscan: /' "$keyscan_error" || true
fi
exit 1
run: bash .github/scripts/setup-demo-ssh.sh
- name: Verify target host identity
env:

View file

@ -1183,10 +1183,11 @@ Those same governed demo deploy/update workflows also own the runner-to-host
network path. They must establish the canonical Tailscale connectivity step
before SSH setup so stable or preview targets may stay on governed private
hostnames or Tailscale IPs, rather than silently depending on public SSH
reachability from GitHub-hosted runners. After Tailscale setup, SSH setup must
wait for the configured demo host to resolve and return host keys with bounded
reachability from GitHub-hosted runners. After Tailscale setup, shared SSH
setup must wait for configured demo hostnames to resolve, accept configured IP
literals without a DNS precheck, and then capture host keys with bounded
retries before any installer or binary copy runs; a one-shot `ssh-keyscan`
against a private demo hostname is not sufficient release or deploy proof.
against a private demo target is not sufficient release or deploy proof.
Those same workflows also own customer-visible browser truth for the public
demo shell. Health checks and entry-asset parity are necessary but not
sufficient; after those checks pass, the governed helpers

View file

@ -947,10 +947,7 @@ func TestDeployDemoWorkflowFailsClosedForStableAndVerifiesFrontendParity(t *test
` - stable`,
`Capture expected frontend entry asset`,
`Verify target host identity`,
`MAX_SSH_SETUP_ATTEMPTS=18`,
`getent hosts "$DEMO_SERVER_HOST"`,
`ssh-keyscan -T 10 -H "$DEMO_SERVER_HOST"`,
`Timed out waiting for the demo SSH host to resolve and return host keys after Tailscale setup.`,
`bash .github/scripts/setup-demo-ssh.sh`,
`SERVICE_NAME="pulse"`,
`Unsupported demo target: ${TARGET}`,
`Demo environment points at host $REMOTE_HOSTNAME but expected $DEMO_EXPECTED_HOSTNAME.`,
@ -991,10 +988,7 @@ func TestUpdateDemoWorkflowUsesGovernedNetworkPath(t *testing.T) {
`go run ./scripts/release_update_key.go public-key-ssh`,
`sed -i "s|^PINNED_RELEASE_SSH_PUBLIC_KEY=.*|PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"|" /tmp/pulse-install.sh`,
`Verify target host identity`,
`MAX_SSH_SETUP_ATTEMPTS=18`,
`getent hosts "$DEMO_SERVER_HOST"`,
`ssh-keyscan -T 10 -H "$DEMO_SERVER_HOST"`,
`Timed out waiting for the demo SSH host to resolve and return host keys after Tailscale setup.`,
`bash .github/scripts/setup-demo-ssh.sh`,
`Demo environment points at host $REMOTE_HOSTNAME but expected $DEMO_EXPECTED_HOSTNAME.`,
`Prepare demo host storage`,
`KEEP_BACKUPS=2`,
@ -1025,6 +1019,69 @@ func TestUpdateDemoWorkflowUsesGovernedNetworkPath(t *testing.T) {
}
}
func TestDemoSshSetupHelperHandlesIpLiteralTargets(t *testing.T) {
helperBytes, err := os.ReadFile(repoFile(".github", "scripts", "setup-demo-ssh.sh"))
if err != nil {
t.Fatalf("read demo ssh setup helper: %v", err)
}
helper := string(helperBytes)
required := []string{
`is_ip_literal()`,
`ipaddress.ip_address(sys.argv[1])`,
`host_needs_dns=false`,
`Demo SSH host is an IP literal; skipping DNS resolution wait.`,
`[ "$host_needs_dns" = "true" ] && ! getent hosts "$DEMO_SERVER_HOST"`,
`ssh-keyscan -T 10 -H "$DEMO_SERVER_HOST"`,
`MAX_SSH_SETUP_ATTEMPTS=18`,
`Timed out waiting for the demo SSH host to become reachable and return host keys after Tailscale setup.`,
}
for _, needle := range required {
if !strings.Contains(helper, needle) {
t.Fatalf("demo ssh setup helper missing guarded IP/hostname behavior: %s", needle)
}
}
tmpDir := t.TempDir()
fakeBin := filepath.Join(tmpDir, "bin")
if err := os.MkdirAll(fakeBin, 0o755); err != nil {
t.Fatalf("create fake bin: %v", err)
}
getentMarker := filepath.Join(tmpDir, "getent-called")
if err := os.WriteFile(filepath.Join(fakeBin, "getent"), []byte("#!/bin/sh\n: > \"$GETENT_MARKER\"\nexit 1\n"), 0o755); err != nil {
t.Fatalf("write fake getent: %v", err)
}
if err := os.WriteFile(filepath.Join(fakeBin, "ssh-keyscan"), []byte("#!/bin/sh\nprintf '100.109.163.95 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDemo\\n'\n"), 0o755); err != nil {
t.Fatalf("write fake ssh-keyscan: %v", err)
}
homeDir := filepath.Join(tmpDir, "home")
cmd := exec.Command("bash", repoFile(".github", "scripts", "setup-demo-ssh.sh"))
cmd.Env = append(os.Environ(),
"DEMO_SERVER_HOST=100.109.163.95",
"DEMO_SERVER_SSH_KEY=fake-private-key",
"GETENT_MARKER="+getentMarker,
"HOME="+homeDir,
"PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"),
)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("demo ssh setup helper failed for IP literal: %v\n%s", err, output)
}
if _, err := os.Stat(getentMarker); !os.IsNotExist(err) {
t.Fatalf("demo ssh setup helper must not require getent hosts for IP literals; stat err=%v", err)
}
knownHosts, err := os.ReadFile(filepath.Join(homeDir, ".ssh", "known_hosts"))
if err != nil {
t.Fatalf("read generated known_hosts: %v", err)
}
if !strings.Contains(string(knownHosts), "ssh-ed25519") {
t.Fatalf("known_hosts missing captured key: %s", knownHosts)
}
if !strings.Contains(string(output), "Demo SSH host is an IP literal; skipping DNS resolution wait.") {
t.Fatalf("helper output did not report IP literal path: %s", output)
}
}
func TestDemoPublicBrowserSmokeWaitsForVisibleLoginUI(t *testing.T) {
scriptBytes, err := os.ReadFile(repoFile("scripts", "demo_public_browser_smoke.cjs"))
if err != nil {

View file

@ -526,6 +526,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
content = read(".github/workflows/create-release.yml")
update_demo_workflow = read(".github/workflows/update-demo-server.yml")
deploy_demo_workflow = read(".github/workflows/deploy-demo-server.yml")
demo_ssh_helper = read(".github/scripts/setup-demo-ssh.sh")
validation_workflow = read(".github/workflows/validate-release-assets.yml")
helper = read("scripts/trigger-release.sh")
renderer = read("scripts/release_control/render_release_body.py")
@ -612,10 +613,13 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn("TRUSTED_SSH_PUBLIC_KEY", update_demo_workflow)
self.assertIn('sed -i "s|^PINNED_RELEASE_SSH_PUBLIC_KEY=.*|PINNED_RELEASE_SSH_PUBLIC_KEY=\\"${TRUSTED_SSH_PUBLIC_KEY}\\"|" /tmp/pulse-install.sh', update_demo_workflow)
for demo_workflow in (update_demo_workflow, deploy_demo_workflow):
self.assertIn("MAX_SSH_SETUP_ATTEMPTS=18", demo_workflow)
self.assertIn('getent hosts "$DEMO_SERVER_HOST"', demo_workflow)
self.assertIn('ssh-keyscan -T 10 -H "$DEMO_SERVER_HOST"', demo_workflow)
self.assertIn("Timed out waiting for the demo SSH host to resolve and return host keys after Tailscale setup", demo_workflow)
self.assertIn("bash .github/scripts/setup-demo-ssh.sh", demo_workflow)
self.assertIn("MAX_SSH_SETUP_ATTEMPTS=18", demo_ssh_helper)
self.assertIn("ipaddress.ip_address(sys.argv[1])", demo_ssh_helper)
self.assertIn("host_needs_dns=false", demo_ssh_helper)
self.assertIn('getent hosts "$DEMO_SERVER_HOST"', demo_ssh_helper)
self.assertIn('ssh-keyscan -T 10 -H "$DEMO_SERVER_HOST"', demo_ssh_helper)
self.assertIn("Timed out waiting for the demo SSH host to become reachable and return host keys after Tailscale setup", demo_ssh_helper)
self.assertIn("derive the OpenSSH installer trust key from `PULSE_UPDATE_SIGNING_PUBLIC_KEY`", normalize_ws(contract))
self.assertIn('SYFT_VERSION="1.42.4"', content)
self.assertIn('SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz"', content)