mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Harden demo SSH setup for IP targets
This commit is contained in:
parent
76ced45c3a
commit
2be167331d
6 changed files with 144 additions and 85 deletions
65
.github/scripts/setup-demo-ssh.sh
vendored
Normal file
65
.github/scripts/setup-demo-ssh.sh
vendored
Normal 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
|
||||
36
.github/workflows/deploy-demo-server.yml
vendored
36
.github/workflows/deploy-demo-server.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
36
.github/workflows/update-demo-server.yml
vendored
36
.github/workflows/update-demo-server.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue