From f3b65054c88b73fa7d7b1844fecbdd6993e2d93e Mon Sep 17 00:00:00 2001 From: Moenarch Date: Thu, 16 Apr 2026 10:59:19 +0200 Subject: [PATCH 1/2] Add dashboard controls for creating websites - add an admin form for deploy_repo-based site creation - route add-site actions through the deploy registry and return updated config - cover the new workflow with UI and control tests --- monitor/webapp/app/api/actions/route.ts | 18 +- monitor/webapp/app/globals.css | 17 ++ monitor/webapp/components/dashboard.test.tsx | 2 + monitor/webapp/components/dashboard.tsx | 186 +++++++++++++++++++ monitor/webapp/lib/control.test.ts | 103 ++++++++++ monitor/webapp/lib/control.ts | 85 ++++++++- 6 files changed, 404 insertions(+), 7 deletions(-) diff --git a/monitor/webapp/app/api/actions/route.ts b/monitor/webapp/app/api/actions/route.ts index 9690589..1c2caef 100644 --- a/monitor/webapp/app/api/actions/route.ts +++ b/monitor/webapp/app/api/actions/route.ts @@ -1,7 +1,11 @@ import { NextResponse } from "next/server"; import { requestHasAdminAccess } from "@/lib/auth"; -import { runDashboardAction, type DashboardActionRequest } from "@/lib/control"; +import { + readEditableConfig, + runDashboardAction, + type DashboardActionRequest, +} from "@/lib/control"; export const dynamic = "force-dynamic"; export const revalidate = 0; @@ -38,6 +42,15 @@ function parseActionRequest(body: unknown): DashboardActionRequest { throw new Error("This action requires a non-empty siteName."); } return { action: record.action, siteName: record.siteName.trim() }; + case "add-site": + return { + action: "add-site", + repoUrl: typeof record.repoUrl === "string" ? record.repoUrl : "", + branch: typeof record.branch === "string" ? record.branch : "", + checkoutPath: typeof record.checkoutPath === "string" ? record.checkoutPath : "", + email: typeof record.email === "string" ? record.email : "", + skipGithubHook: record.skipGithubHook === true, + }; default: throw new Error("Unsupported action."); } @@ -52,7 +65,8 @@ export async function POST(request: Request) { const body = parseActionRequest(await request.json()); const response = await runDashboardAction(body); - return NextResponse.json(response, { + const config = await readEditableConfig(); + return NextResponse.json({ ...response, config }, { headers: { "cache-control": "no-store", }, diff --git a/monitor/webapp/app/globals.css b/monitor/webapp/app/globals.css index 953b605..293f9ee 100644 --- a/monitor/webapp/app/globals.css +++ b/monitor/webapp/app/globals.css @@ -311,6 +311,7 @@ main { .button-grid, .token-form, .token-field, +.toggle-field, .action-result, .site-actions, .deploy-panel, @@ -360,6 +361,22 @@ main { font: inherit; } +.toggle-field { + grid-auto-flow: column; + grid-auto-columns: max-content 1fr; + align-items: center; +} + +.toggle-field input { + width: 18px; + height: 18px; + accent-color: #78e08f; +} + +.toggle-field span { + color: var(--text); +} + .token-field input, .config-editor { width: 100%; diff --git a/monitor/webapp/components/dashboard.test.tsx b/monitor/webapp/components/dashboard.test.tsx index 430739f..3a104f1 100644 --- a/monitor/webapp/components/dashboard.test.tsx +++ b/monitor/webapp/components/dashboard.test.tsx @@ -149,8 +149,10 @@ test("dashboard renders important issues and setup details", () => { assert.match(markup, /What needs attention/); assert.match(markup, /Config and recovery controls/); + assert.match(markup, /Add website/); assert.match(markup, /Push to Main/); assert.match(markup, /Retry deploy/); + assert.match(markup, /The repository must already include a valid root `server\.conf`\./); assert.match(markup, /Automation: Apps watcher/); assert.match(markup, /Bootstrap and operations signals/); assert.match(markup, /api\.service \(failed\)/); diff --git a/monitor/webapp/components/dashboard.tsx b/monitor/webapp/components/dashboard.tsx index 2b02a62..384c02b 100644 --- a/monitor/webapp/components/dashboard.tsx +++ b/monitor/webapp/components/dashboard.tsx @@ -22,6 +22,22 @@ type DashboardProps = { adminControlsEnabled: boolean; }; +type NewSiteDraft = { + repoUrl: string; + branch: string; + checkoutPath: string; + email: string; + skipGithubHook: boolean; +}; + +const EMPTY_NEW_SITE_DRAFT: NewSiteDraft = { + repoUrl: "", + branch: "", + checkoutPath: "", + email: "", + skipGithubHook: false, +}; + function formatMetric(value: number | null, suffix = ""): string { if (value === null || Number.isNaN(value)) { return "n/a"; @@ -119,6 +135,8 @@ function actionLabel(action: DashboardActionRequest["action"]): string { return "Restart service"; case "retry-deploy": return "Retry deploy"; + case "add-site": + return "Add website"; } } @@ -168,6 +186,7 @@ export function Dashboard({ initialSnapshot, adminControlsEnabled }: DashboardPr deriveSiteDrafts(initialSnapshot) ); const [siteSaveKey, setSiteSaveKey] = useState(null); + const [newSiteDraft, setNewSiteDraft] = useState(EMPTY_NEW_SITE_DRAFT); const refreshSnapshot = useEffectEvent(async () => { try { @@ -317,8 +336,13 @@ export function Dashboard({ initialSnapshot, adminControlsEnabled }: DashboardPr const payload = (await response.json()) as { result: DashboardActionResult; snapshot: DashboardSnapshot; + config?: EditableConfigDocument; }; startTransition(() => { + if (payload.config) { + setConfigDocument(payload.config); + setConfigDraft(payload.config.raw); + } setSnapshot(payload.snapshot); setSiteDrafts(deriveSiteDrafts(payload.snapshot)); setActionResult(payload.result); @@ -336,6 +360,76 @@ export function Dashboard({ initialSnapshot, adminControlsEnabled }: DashboardPr } }); + const updateNewSiteDraft = useEffectEvent( + (field: keyof Omit, value: string) => { + setNewSiteDraft((current) => ({ + ...current, + [field]: value, + })); + } + ); + + const setNewSiteSkipGithubHook = useEffectEvent((checked: boolean) => { + setNewSiteDraft((current) => ({ + ...current, + skipGithubHook: checked, + })); + }); + + const createSite = useEffectEvent(async () => { + const trimmedToken = adminToken.trim(); + if (!trimmedToken) { + setAdminMessage("Admin token is missing."); + return; + } + + if (!newSiteDraft.repoUrl.trim()) { + setAdminMessage("Repository URL is required to deploy a website."); + return; + } + + setBusyActionKey("add-site"); + try { + const response = await fetch("/api/actions", { + method: "POST", + headers: adminHeaders(trimmedToken), + body: JSON.stringify({ + action: "add-site", + ...newSiteDraft, + }), + }); + if (!response.ok) { + throw new Error(await readError(response)); + } + + const payload = (await response.json()) as { + result: DashboardActionResult; + snapshot: DashboardSnapshot; + config?: EditableConfigDocument; + }; + startTransition(() => { + if (payload.config) { + setConfigDocument(payload.config); + setConfigDraft(payload.config.raw); + } + setSnapshot(payload.snapshot); + setSiteDrafts(deriveSiteDrafts(payload.snapshot)); + setActionResult(payload.result); + setNewSiteDraft(EMPTY_NEW_SITE_DRAFT); + setAdminMessage(null); + setError(null); + }); + } catch (createError) { + const message = + createError instanceof Error ? createError.message : "Unable to deploy the new website."; + startTransition(() => { + setAdminMessage(message); + }); + } finally { + setBusyActionKey(null); + } + }); + const discardConfigChanges = useEffectEvent(() => { if (!configDocument) { return; @@ -575,6 +669,98 @@ export function Dashboard({ initialSnapshot, adminControlsEnabled }: DashboardPr ) : null} + +
+
+
+

Add website

+

+ Deploy a new repository through the same `deploy-repo` workflow used on the shell. +

+
+
+
+ + + + + +
+ +
+
+

+ The repository must already include a valid root `server.conf`. Blank branch, path, and + email fields fall back to the existing deploy defaults. +

+ {configDocument?.kind === "monitor" ? ( +

+ The first successful deployment will create `deploy/registry.json` and switch the + dashboard over to the deploy registry. +

+ ) : null} +
diff --git a/monitor/webapp/lib/control.test.ts b/monitor/webapp/lib/control.test.ts index 8de2454..fd227d5 100644 --- a/monitor/webapp/lib/control.test.ts +++ b/monitor/webapp/lib/control.test.ts @@ -220,6 +220,109 @@ exit 0 assert.match(loggedCommand, /scripts\/deploy_repo\.py --repo-url https:\/\/github\.com\/example\/app\.git --dest \/srv\/apps\/app --branch main --skip-github-hook/); }); +test("runDashboardAction can add a new site through deploy_repo", async () => { + const tmpDir = await mkdtemp(path.join(os.tmpdir(), "status-webapp-control-")); + const rootDir = path.join(tmpDir, "root"); + const scriptsDir = path.join(rootDir, "scripts"); + const binDir = path.join(tmpDir, "bin"); + const logsDir = path.join(tmpDir, "logs"); + const registryPath = path.join(rootDir, "deploy", "registry.json"); + const automationEnvPath = path.join(tmpDir, "site-automation"); + const sshConfigPath = path.join(tmpDir, "sshd.conf"); + + await mkdir(scriptsDir, { recursive: true }); + await mkdir(binDir); + await mkdir(logsDir); + await writeFile(path.join(scriptsDir, "deploy_repo.py"), "print('stub deploy')\n", "utf-8"); + await writeFile(automationEnvPath, "WEBHOOK_SECRET=test\nDEFAULT_TLS_EMAIL=ops@example.com\n", "utf-8"); + await writeFile(sshConfigPath, "PasswordAuthentication no\nPermitRootLogin no\n", "utf-8"); + + await writeExecutable( + path.join(binDir, "systemctl"), + `#!/usr/bin/env bash +command="$1" +case "$command" in + is-active) + printf 'active\\n' + exit 0 + ;; + *) + exit 0 + ;; +esac +` + ); + await writeExecutable( + path.join(binDir, "python3"), + `#!/usr/bin/env bash +printf '%s\\n' "$*" >>"${logsDir}/python3.log" +mkdir -p "$(dirname "$REGISTRY_PATH")" +cat >"$REGISTRY_PATH" <<'JSON' +[ + { + "name": "new-app", + "repo_url": "https://github.com/example/new-app.git", + "branch": "main", + "checkout_path": "/srv/apps/new-app", + "domain": "new-app.example.com", + "webhook_repo": "example/new-app", + "deploy_config": { + "name": "new-app", + "domain": "new-app.example.com", + "runtime": { + "mode": "static" + } + } + } +] +JSON +` + ); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response("ok", { + status: 200, + headers: { + "content-type": "text/plain", + }, + }); + + try { + await withEnv( + { + SERVER_SETUP_ROOT: rootDir, + PATH: `${binDir}:${process.env.PATH || ""}`, + STATUS_AUTOMATION_ENV_FILE: automationEnvPath, + STATUS_SSH_HARDENING_CONFIG: sshConfigPath, + STATUS_LETSENCRYPT_LIVE_DIR: path.join(tmpDir, "letsencrypt"), + }, + async () => { + const response = await runDashboardAction({ + action: "add-site", + repoUrl: "https://github.com/example/new-app.git", + branch: "main", + checkoutPath: "/srv/apps/new-app", + email: "ops@example.com", + skipGithubHook: true, + }); + + assert.equal(response.result.action, "add-site"); + assert.equal(response.snapshot.applications[0]?.name, "new-app"); + + const config = await readEditableConfig(); + assert.equal(config.path, registryPath); + assert.equal(config.kind, "registry"); + } + ); + } finally { + globalThis.fetch = originalFetch; + } + + const loggedCommand = await readFile(path.join(logsDir, "python3.log"), "utf-8"); + assert.match(loggedCommand, /scripts\/deploy_repo\.py --repo-url https:\/\/github\.com\/example\/new-app\.git --dest \/srv\/apps\/new-app --branch main --email ops@example\.com --skip-github-hook/); +}); + test("updateSiteDeploymentSettings patches registry metadata", async () => { const tmpDir = await mkdtemp(path.join(os.tmpdir(), "status-webapp-control-")); const configPath = path.join(tmpDir, "registry.json"); diff --git a/monitor/webapp/lib/control.ts b/monitor/webapp/lib/control.ts index d51a3d6..ab1ff15 100644 --- a/monitor/webapp/lib/control.ts +++ b/monitor/webapp/lib/control.ts @@ -19,7 +19,15 @@ export type DashboardActionRequest = | { action: "restart-webhook" } | { action: "restart-status-webapp" } | { action: "restart-site-service"; siteName: string } - | { action: "retry-deploy"; siteName: string }; + | { action: "retry-deploy"; siteName: string } + | { + action: "add-site"; + repoUrl: string; + branch: string; + checkoutPath: string; + email: string; + skipGithubHook: boolean; + }; export type DashboardActionResult = { action: DashboardActionRequest["action"]; @@ -38,6 +46,10 @@ export type SiteDeploymentSettings = { }; type JsonRecord = Record; +type CommandOptions = { + timeout?: number; + env?: NodeJS.ProcessEnv; +}; function repoRoot(): string { return process.env.SERVER_SETUP_ROOT || path.resolve(process.cwd(), "..", ".."); @@ -144,6 +156,13 @@ function readSiteName(value: unknown): string { return value.trim(); } +function readRepoUrl(value: unknown): string { + if (typeof value !== "string" || !value.trim()) { + throw new Error("A non-empty repository URL is required."); + } + return value.trim(); +} + function asRecord(value: unknown): JsonRecord { return typeof value === "object" && value !== null ? (value as JsonRecord) : {}; } @@ -175,13 +194,14 @@ async function runCommand( args: string[], successSummary: string, target: string | null, - timeout = 15 * 60 * 1000 + options: CommandOptions = {} ): Promise { try { const { stdout = "", stderr = "" } = await execFileAsync(command, args, { cwd: repoRoot(), - timeout, + timeout: options.timeout ?? 15 * 60 * 1000, maxBuffer: 1024 * 1024, + env: options.env, }); return { action: "reload-nginx", @@ -211,15 +231,33 @@ async function runTypedCommand( args: string[], successSummary: string, target: string | null, - timeout?: number + options?: CommandOptions ): Promise { - const result = await runCommand(command, args, successSummary, target, timeout); + const result = await runCommand(command, args, successSummary, target, options); return { ...result, action, }; } +async function resolveDeployRegistryPath(): Promise { + const configPath = await defaultConfigPath(); + const payload = (await pathExists(configPath)) ? await readJsonFile(configPath) : []; + const configKind = inferConfigKind(payload, configPath); + + if (configKind === "registry") { + return configPath; + } + + if (process.env.STATUS_CONFIG_PATH?.trim()) { + throw new Error( + "Website creation requires the deploy registry as the active status source. Point STATUS_CONFIG_PATH at deploy/registry.json or remove the override." + ); + } + + return resolveRepoPath("deploy/registry.json"); +} + export async function readEditableConfig(): Promise { const configPath = await defaultConfigPath(); const raw = (await pathExists(configPath)) ? await fs.readFile(configPath, "utf-8") : "[]\n"; @@ -384,6 +422,43 @@ export async function runDashboardAction( ); break; } + case "add-site": { + const repoUrl = readRepoUrl(request.repoUrl); + const branch = request.branch.trim(); + const checkoutPath = request.checkoutPath.trim(); + const email = request.email.trim(); + const registryPath = await resolveDeployRegistryPath(); + + const args = [resolveRepoPath("scripts/deploy_repo.py"), "--repo-url", repoUrl]; + if (checkoutPath) { + args.push("--dest", checkoutPath); + } + if (branch) { + args.push("--branch", branch); + } + if (email) { + args.push("--email", email); + } + if (request.skipGithubHook) { + args.push("--skip-github-hook"); + } + + result = await runTypedCommand( + "add-site", + "python3", + args, + "Website deployment finished.", + repoUrl, + { + timeout: 30 * 60 * 1000, + env: { + ...process.env, + REGISTRY_PATH: registryPath, + }, + } + ); + break; + } } return { From 778ddb6aca57dc128905158e73feafa136b48247 Mon Sep 17 00:00:00 2001 From: Moenarch Date: Thu, 16 Apr 2026 13:34:17 +0200 Subject: [PATCH 2/2] Convert server setup scripts to direct Python entrypoints - remove bash wrapper scripts and call Python modules directly - make SSH hardening opt-in and preserve status webapp admin token - update docs, services, and tests for the new entrypoints --- Dockerfile | 3 +- INSTALLING-AND-TESTING.md | 8 +-- README.md | 18 +++--- benchmarks/discover-sites-benchmark.sh | 2 +- .../simple-site/public/about.html | 4 +- ops/systemd/server-setup-example-apps.service | 2 +- .../server-setup-status-webapp.service | 2 +- scripts/deploy-repo.sh | 5 -- scripts/ensure-server-tools.sh | 5 -- scripts/harden-server.sh | 5 -- scripts/harden_server.py | 27 +++++--- scripts/install-nginx-site.sh | 5 -- scripts/manage-services.sh | 5 -- scripts/prepare-server.sh | 5 -- scripts/prepare_server.py | 15 +++-- scripts/reset-server-setup.sh | 5 -- scripts/run-self-checks.sh | 5 -- scripts/sandbox-entrypoint.sh | 5 -- scripts/seed-example-repositories.sh | 5 -- scripts/setup-letsencrypt.sh | 5 -- scripts/setup-status-webapp.sh | 5 -- scripts/setup_letsencrypt.py | 4 ++ scripts/setup_status_webapp.py | 30 ++++++--- scripts/shutdown-server.sh | 5 -- scripts/shutdown-websites.sh | 5 -- scripts/start-example-apps.sh | 5 -- scripts/start-status-webapp.sh | 5 -- scripts/start_example_apps.py | 4 +- server.conf | 2 +- tests/test_docker_sandbox.sh | 4 +- tests/test_harden_server.py | 53 ++++++++++++++++ tests/test_prepare_server.py | 62 ++++++++++++++++++- tests/test_python_entrypoints.sh | 20 ------ tests/test_seed_example_repositories.sh | 10 ++- tests/test_setup_letsencrypt.py | 52 ++++++++++++++++ tests/test_setup_status_webapp.py | 52 ++++++++++++++++ tests/test_setup_status_webapp.sh | 2 +- 37 files changed, 303 insertions(+), 153 deletions(-) delete mode 100755 scripts/deploy-repo.sh delete mode 100755 scripts/ensure-server-tools.sh delete mode 100755 scripts/harden-server.sh delete mode 100755 scripts/install-nginx-site.sh delete mode 100755 scripts/manage-services.sh delete mode 100755 scripts/prepare-server.sh delete mode 100755 scripts/reset-server-setup.sh delete mode 100755 scripts/run-self-checks.sh delete mode 100755 scripts/sandbox-entrypoint.sh delete mode 100755 scripts/seed-example-repositories.sh delete mode 100755 scripts/setup-letsencrypt.sh delete mode 100755 scripts/setup-status-webapp.sh delete mode 100755 scripts/shutdown-server.sh delete mode 100755 scripts/shutdown-websites.sh delete mode 100755 scripts/start-example-apps.sh delete mode 100755 scripts/start-status-webapp.sh create mode 100644 tests/test_harden_server.py create mode 100644 tests/test_setup_letsencrypt.py create mode 100644 tests/test_setup_status_webapp.py diff --git a/Dockerfile b/Dockerfile index cd18ce4..d48995c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,7 +49,6 @@ RUN apt-get update \ COPY . /opt/server-setup RUN chmod +x \ - /opt/server-setup/scripts/*.sh \ /opt/server-setup/tests/*.sh \ /opt/server-setup/benchmarks/*.sh \ && mkdir -p \ @@ -100,6 +99,6 @@ EXPOSE 22 80 443 4000 4001 4002 4003 STOPSIGNAL SIGRTMIN+3 -ENTRYPOINT ["/opt/server-setup/scripts/sandbox-entrypoint.sh"] +ENTRYPOINT ["/usr/bin/env", "python3", "/opt/server-setup/scripts/sandbox_entrypoint.py"] CMD ["/sbin/init"] diff --git a/INSTALLING-AND-TESTING.md b/INSTALLING-AND-TESTING.md index 384f728..72a160d 100644 --- a/INSTALLING-AND-TESTING.md +++ b/INSTALLING-AND-TESTING.md @@ -37,7 +37,7 @@ Run only the status webapp tests: Run the self-check wrapper: ```bash -./scripts/run-self-checks.sh +python3 ./scripts/run_self_checks.py ``` ## Docker sandbox @@ -69,9 +69,9 @@ cd /opt/server-setup Useful sandbox commands: ```bash -./scripts/prepare-server.sh --email admin@example.com --skip-docker -./scripts/deploy-repo.sh --repo-url /srv/apps/simple-site --dest /srv/apps/simple-site --email admin@example.com --skip-github-hook -./scripts/manage-services.sh +python3 ./scripts/prepare_server.py --email admin@example.com --skip-docker +python3 ./scripts/deploy_repo.py --repo-url /srv/apps/simple-site --dest /srv/apps/simple-site --email admin@example.com --skip-github-hook +python3 ./scripts/manage_services.py ``` ## Examples diff --git a/README.md b/README.md index e5d4c81..e13d006 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The committed [`deploy/registry.example.json`](deploy/registry.example.json) fil ## Prepare the server ```bash -sudo ./scripts/prepare-server.sh \ +sudo python3 ./scripts/prepare_server.py \ --email ops@example.com \ --skip-docker \ --with-status-webapp @@ -24,11 +24,13 @@ sudo ./scripts/prepare-server.sh \ What it does: - installs baseline packages and developer tools -- optionally applies SSH/UFW/fail2ban hardening +- optionally applies unattended-upgrades/UFW/fail2ban hardening - installs the webhook receiver service - stores `DEFAULT_TLS_EMAIL` in `/etc/default/site-automation` - optionally installs the status webapp +`prepare_server.py` leaves SSH untouched by default. If you explicitly want it to manage `sshd`, add `--with-ssh-hardening`. + The Next.js status webapp is the supported dashboard for this repository. If you want to use its admin controls: @@ -41,14 +43,14 @@ If you want to use its admin controls: ## Deploy a repository ```bash -sudo ./scripts/deploy-repo.sh \ +sudo python3 ./scripts/deploy_repo.py \ --repo-url git@github.com:your-org/your-app.git ``` Optional: ```bash -sudo ./scripts/deploy-repo.sh \ +sudo python3 ./scripts/deploy_repo.py \ --repo-url git@github.com:your-org/your-app.git \ --dest /srv/apps/your-app \ --branch main \ @@ -129,25 +131,25 @@ Legacy top-level shorthand like `build`, `command`, `port`, `www_redirect`, and Show managed services: ```bash -./scripts/manage-services.sh +python3 ./scripts/manage_services.py ``` Restart one app service: ```bash -sudo ./scripts/manage-services.sh restart --app your-app +sudo python3 ./scripts/manage_services.py restart --app your-app ``` Stop managed services: ```bash -sudo ./scripts/shutdown-server.sh +sudo python3 ./scripts/shutdown_server.py ``` Preview purge: ```bash -sudo ./scripts/shutdown-server.sh --purge --dry-run +sudo python3 ./scripts/shutdown_server.py --purge --dry-run ``` ## Legacy Migration diff --git a/benchmarks/discover-sites-benchmark.sh b/benchmarks/discover-sites-benchmark.sh index 0e6673a..1ceb471 100755 --- a/benchmarks/discover-sites-benchmark.sh +++ b/benchmarks/discover-sites-benchmark.sh @@ -34,7 +34,7 @@ mkdir -p "$tmp/apps/server-setup-bench" "domain": "bench.local", "build_output": ".", "deploy_hooks": { - "build": "./scripts/run-self-checks.sh" + "build": "python3 ./scripts/run_self_checks.py" }, "runtime": { "mode": "static" diff --git a/examples/repositories/simple-site/public/about.html b/examples/repositories/simple-site/public/about.html index 729e7be..ff1d241 100644 --- a/examples/repositories/simple-site/public/about.html +++ b/examples/repositories/simple-site/public/about.html @@ -16,8 +16,8 @@

What This Repo Is For

  1. The seeding script turns this folder into a standalone git repo under /srv/apps/simple-site.
  2. -
  3. deploy-repo.sh validates its server.conf and writes the generated registry entry.
  4. -
  5. deploy-repo.sh publishes it through Nginx and configures webhook redeploys.
  6. +
  7. deploy_repo.py validates its server.conf and writes the generated registry entry.
  8. +
  9. deploy_repo.py publishes it through Nginx and configures webhook redeploys.

Back to the home page

diff --git a/ops/systemd/server-setup-example-apps.service b/ops/systemd/server-setup-example-apps.service index a55a2cc..17d0838 100644 --- a/ops/systemd/server-setup-example-apps.service +++ b/ops/systemd/server-setup-example-apps.service @@ -6,7 +6,7 @@ Wants=network-online.target [Service] Type=oneshot Environment=EXAMPLE_APPS_DIR=/srv/apps -ExecStart=/usr/bin/env bash /opt/server-setup/scripts/start-example-apps.sh +ExecStart=/usr/bin/env python3 /opt/server-setup/scripts/start_example_apps.py RemainAfterExit=yes [Install] diff --git a/ops/systemd/server-setup-status-webapp.service b/ops/systemd/server-setup-status-webapp.service index 69a62c3..b4e01aa 100644 --- a/ops/systemd/server-setup-status-webapp.service +++ b/ops/systemd/server-setup-status-webapp.service @@ -11,7 +11,7 @@ Environment=BUN_INSTALL=/root/.bun Environment=STATUS_WEBAPP_HOST=0.0.0.0 Environment=STATUS_WEBAPP_PORT=4000 WorkingDirectory=/opt/server-setup/monitor/webapp -ExecStart=/usr/bin/env bash /opt/server-setup/scripts/start-status-webapp.sh +ExecStart=/usr/bin/env python3 /opt/server-setup/scripts/start_status_webapp.py Restart=always RestartSec=2 diff --git a/scripts/deploy-repo.sh b/scripts/deploy-repo.sh deleted file mode 100755 index b1ffcaa..0000000 --- a/scripts/deploy-repo.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -exec python3 "$SCRIPT_DIR/deploy_repo.py" "$@" diff --git a/scripts/ensure-server-tools.sh b/scripts/ensure-server-tools.sh deleted file mode 100755 index 37668ca..0000000 --- a/scripts/ensure-server-tools.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -exec python3 "$SCRIPT_DIR/ensure_server_tools.py" "$@" diff --git a/scripts/harden-server.sh b/scripts/harden-server.sh deleted file mode 100755 index 92e48bc..0000000 --- a/scripts/harden-server.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -exec python3 "$SCRIPT_DIR/harden_server.py" "$@" diff --git a/scripts/harden_server.py b/scripts/harden_server.py index a969fc6..652afa8 100755 --- a/scripts/harden_server.py +++ b/scripts/harden_server.py @@ -23,8 +23,13 @@ def die(message: str) -> None: raise SystemExit(1) -def run_checked(cmd: list[str], allow_fail: bool = False) -> subprocess.CompletedProcess[str]: - result = subprocess.run(cmd, text=True, capture_output=True, check=False) +def run_checked( + cmd: list[str], + *, + env: dict[str, str] | None = None, + allow_fail: bool = False, +) -> subprocess.CompletedProcess[str]: + result = subprocess.run(cmd, text=True, capture_output=True, env=env, check=False) if result.stdout: sys.stdout.write(result.stdout) if result.stderr: @@ -42,7 +47,7 @@ def require_root() -> None: def install_pkgs(packages: list[str]) -> None: env = os.environ.copy() env["DEBIAN_FRONTEND"] = "noninteractive" - run_checked(["apt-get", "install", "-y", *packages],) + run_checked(["apt-get", "install", "-y", *packages], env=env) def restart_or_enable_service(service_name: str) -> None: @@ -82,7 +87,7 @@ def write_sshd_hardening_config() -> None: cfg = Path("/etc/ssh/sshd_config.d/99-server-setup-hardening.conf") cfg.parent.mkdir(parents=True, exist_ok=True) cfg.write_text( - "# Managed by scripts/harden-server.sh\n" + "# Managed by scripts/harden_server.py\n" "Protocol 2\n" "PasswordAuthentication no\n" "KbdInteractiveAuthentication no\n" @@ -161,14 +166,22 @@ def configure_ufw() -> None: def main() -> None: parser = argparse.ArgumentParser(description="Configure baseline host hardening.") - parser.parse_args() + parser.add_argument( + "--configure-ssh", + action="store_true", + help="Also manage sshd settings. Default behavior leaves SSH unchanged.", + ) + args = parser.parse_args() require_root() if shutil.which("apt-get") is None: die("This script currently supports apt-based systems only.") log("Refreshing apt package index") run_checked(["apt-get", "update", "-y"]) - ensure_safe_to_disable_password_auth() - write_sshd_hardening_config() + if args.configure_ssh: + ensure_safe_to_disable_password_auth() + write_sshd_hardening_config() + else: + log("Leaving SSH configuration unchanged. Re-run with --configure-ssh to manage sshd settings.") configure_unattended_upgrades() configure_fail2ban() configure_ufw() diff --git a/scripts/install-nginx-site.sh b/scripts/install-nginx-site.sh deleted file mode 100755 index f0c4265..0000000 --- a/scripts/install-nginx-site.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -exec python3 "$SCRIPT_DIR/install_nginx_site.py" "$@" diff --git a/scripts/manage-services.sh b/scripts/manage-services.sh deleted file mode 100755 index a8a8743..0000000 --- a/scripts/manage-services.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -exec python3 "$SCRIPT_DIR/manage_services.py" "$@" diff --git a/scripts/prepare-server.sh b/scripts/prepare-server.sh deleted file mode 100755 index 4e2e9f5..0000000 --- a/scripts/prepare-server.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -exec python3 "$SCRIPT_DIR/prepare_server.py" "$@" diff --git a/scripts/prepare_server.py b/scripts/prepare_server.py index fe34f12..159e7e2 100644 --- a/scripts/prepare_server.py +++ b/scripts/prepare_server.py @@ -11,6 +11,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--email", default="") parser.add_argument("--skip-docker", action="store_true") parser.add_argument("--skip-hardening", action="store_true") + parser.add_argument("--with-ssh-hardening", action="store_true") parser.add_argument("--with-status-webapp", action="store_true") return parser.parse_args() @@ -25,16 +26,19 @@ def main() -> None: raise SystemExit("--email is required unless DEFAULT_TLS_EMAIL already exists in /etc/default/site-automation") print("[1/4] Installing baseline tools") - cmd = ["bash", str(root / "scripts/ensure-server-tools.sh")] + cmd = ["python3", str(root / "scripts/ensure_server_tools.py")] if args.skip_docker: cmd.append("--skip-docker") run_checked(cmd, cwd=root) if args.skip_hardening: - print("[2/4] Skipping SSH/UFW/fail2ban hardening") + print("[2/4] Skipping host hardening") else: - print("[2/4] Applying SSH/UFW/fail2ban hardening") - run_checked(["bash", str(root / "scripts/harden-server.sh")], cwd=root) + print("[2/4] Applying unattended-upgrades/UFW/fail2ban hardening") + hardening_cmd = ["python3", str(root / "scripts/harden_server.py")] + if args.with_ssh_hardening: + hardening_cmd.append("--configure-ssh") + run_checked(hardening_cmd, cwd=root) print("[3/4] Installing deploy automation services") setup_automation_units(root, start_webhook=False) @@ -49,7 +53,7 @@ def main() -> None: if args.with_status_webapp: print("[4/4] Installing status webapp") - run_checked(["bash", str(root / "scripts/setup-status-webapp.sh"), "--root", str(root)], cwd=root) + run_checked(["python3", str(root / "scripts/setup_status_webapp.py"), "--root", str(root)], cwd=root) else: print("[4/4] Status webapp skipped") @@ -58,6 +62,7 @@ def main() -> None: "- Tools: installed\n" f"- Docker: {'skipped' if args.skip_docker else 'installed'}\n" f"- Hardening: {'skipped' if args.skip_hardening else 'applied'}\n" + f"- SSH hardening: {'applied' if args.with_ssh_hardening and not args.skip_hardening else 'unchanged'}\n" f"- Default TLS email: {tls_email}\n" "- Deploy automation: webhook receiver installed\n" "- Webhook receiver: enabled, starts after deploy-repo configures a secret" diff --git a/scripts/reset-server-setup.sh b/scripts/reset-server-setup.sh deleted file mode 100755 index e530d90..0000000 --- a/scripts/reset-server-setup.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -exec python3 "$SCRIPT_DIR/reset_server_setup.py" "$@" diff --git a/scripts/run-self-checks.sh b/scripts/run-self-checks.sh deleted file mode 100755 index 4818da7..0000000 --- a/scripts/run-self-checks.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -exec python3 "$SCRIPT_DIR/run_self_checks.py" "$@" diff --git a/scripts/sandbox-entrypoint.sh b/scripts/sandbox-entrypoint.sh deleted file mode 100755 index 44b9ef8..0000000 --- a/scripts/sandbox-entrypoint.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -exec python3 "$SCRIPT_DIR/sandbox_entrypoint.py" "$@" diff --git a/scripts/seed-example-repositories.sh b/scripts/seed-example-repositories.sh deleted file mode 100755 index 151f6d3..0000000 --- a/scripts/seed-example-repositories.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -exec python3 "$SCRIPT_DIR/seed_example_repositories.py" "$@" diff --git a/scripts/setup-letsencrypt.sh b/scripts/setup-letsencrypt.sh deleted file mode 100755 index 89b3ed8..0000000 --- a/scripts/setup-letsencrypt.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -exec python3 "$SCRIPT_DIR/setup_letsencrypt.py" "$@" diff --git a/scripts/setup-status-webapp.sh b/scripts/setup-status-webapp.sh deleted file mode 100755 index 3370bba..0000000 --- a/scripts/setup-status-webapp.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -exec python3 "$SCRIPT_DIR/setup_status_webapp.py" "$@" diff --git a/scripts/setup_letsencrypt.py b/scripts/setup_letsencrypt.py index c1843a7..a0129ac 100755 --- a/scripts/setup_letsencrypt.py +++ b/scripts/setup_letsencrypt.py @@ -57,6 +57,10 @@ def main() -> None: "--nginx", "--non-interactive", "--agree-tos", + "--keep-until-expiring", + "--expand", + "--cert-name", + args.domain, "--email", args.email, "--redirect", diff --git a/scripts/setup_status_webapp.py b/scripts/setup_status_webapp.py index 895057e..a6b2c46 100755 --- a/scripts/setup_status_webapp.py +++ b/scripts/setup_status_webapp.py @@ -10,6 +10,8 @@ from datetime import datetime, timezone from pathlib import Path +from simple_setup_common import load_env_file, update_env_file + DEFAULT_BUN_INSTALL = "/root/.bun" @@ -94,13 +96,13 @@ def ensure_bun() -> None: raise SystemExit("Bun is still unavailable after installation.") -def render_status_webapp_env(root_dir: str, host: str, port: str) -> str: +def render_status_webapp_env(root_dir: str, host: str, port: str, admin_token: str = "") -> str: return ( f"SERVER_SETUP_ROOT={root_dir}\n" f"BUN_INSTALL={DEFAULT_BUN_INSTALL}\n" f"STATUS_WEBAPP_HOST={host}\n" f"STATUS_WEBAPP_PORT={port}\n" - "STATUS_WEBAPP_ADMIN_TOKEN=\n" + f"STATUS_WEBAPP_ADMIN_TOKEN={admin_token}\n" ) @@ -118,7 +120,7 @@ def render_status_webapp_service(root_dir: str, env_file: str) -> str: "Environment=STATUS_WEBAPP_HOST=0.0.0.0\n" "Environment=STATUS_WEBAPP_PORT=4000\n" f"WorkingDirectory={root_dir}/monitor/webapp\n" - f"ExecStart=/usr/bin/env bash {root_dir}/scripts/start-status-webapp.sh\n" + f"ExecStart=/usr/bin/env python3 {root_dir}/scripts/start_status_webapp.py\n" "Restart=always\n" "RestartSec=2\n\n" "[Install]\n" @@ -155,6 +157,20 @@ def wait_for_status_webapp(port: str) -> None: raise SystemExit(f"Monitoring webapp did not answer on port {port} within the expected time.") +def write_status_webapp_env(env_file: Path, root_dir: str, host: str, port: str) -> None: + existing = load_env_file(env_file) + update_env_file( + env_file, + { + "SERVER_SETUP_ROOT": root_dir, + "BUN_INSTALL": DEFAULT_BUN_INSTALL, + "STATUS_WEBAPP_HOST": host, + "STATUS_WEBAPP_PORT": port, + "STATUS_WEBAPP_ADMIN_TOKEN": existing.get("STATUS_WEBAPP_ADMIN_TOKEN", ""), + }, + ) + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Build and install the status webapp service.") parser.add_argument("--root", default=str(Path(__file__).resolve().parent.parent)) @@ -166,7 +182,7 @@ def parse_args() -> argparse.Namespace: def main() -> None: args = parse_args() root_dir = str(Path(args.root).resolve()) - env_file = "/etc/default/server-setup-status-webapp" + env_file = Path("/etc/default/server-setup-status-webapp") service_name = "server-setup-status-webapp.service" service_path = Path("/etc/systemd/system") / service_name webapp_dir = Path(root_dir) / "monitor/webapp" @@ -177,7 +193,7 @@ def main() -> None: print(render_status_webapp_env(root_dir, status_host, status_port), end="") return if args.render_service: - print(render_status_webapp_service(root_dir, env_file), end="") + print(render_status_webapp_service(root_dir, str(env_file)), end="") return require_root() @@ -186,8 +202,8 @@ def main() -> None: install_pkgs(["ca-certificates", "curl", "unzip"]) ensure_bun() build_status_webapp(webapp_dir) - Path(env_file).write_text(render_status_webapp_env(root_dir, status_host, status_port), encoding="utf-8") - service_path.write_text(render_status_webapp_service(root_dir, env_file), encoding="utf-8") + write_status_webapp_env(env_file, root_dir, status_host, status_port) + service_path.write_text(render_status_webapp_service(root_dir, str(env_file)), encoding="utf-8") enable_service(service_name) wait_for_status_webapp(status_port) diff --git a/scripts/shutdown-server.sh b/scripts/shutdown-server.sh deleted file mode 100755 index a71c780..0000000 --- a/scripts/shutdown-server.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -exec python3 "$SCRIPT_DIR/shutdown_server.py" "$@" diff --git a/scripts/shutdown-websites.sh b/scripts/shutdown-websites.sh deleted file mode 100755 index 9507090..0000000 --- a/scripts/shutdown-websites.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -exec python3 "$SCRIPT_DIR/shutdown_websites.py" "$@" diff --git a/scripts/start-example-apps.sh b/scripts/start-example-apps.sh deleted file mode 100755 index fb40daf..0000000 --- a/scripts/start-example-apps.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -exec python3 "$SCRIPT_DIR/start_example_apps.py" "$@" diff --git a/scripts/start-status-webapp.sh b/scripts/start-status-webapp.sh deleted file mode 100755 index 3ca98c5..0000000 --- a/scripts/start-status-webapp.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -exec python3 "$SCRIPT_DIR/start_status_webapp.py" "$@" diff --git a/scripts/start_example_apps.py b/scripts/start_example_apps.py index 55d5dc7..7fe9099 100755 --- a/scripts/start_example_apps.py +++ b/scripts/start_example_apps.py @@ -33,8 +33,8 @@ def main() -> None: for repo_dir in sorted(path for path in apps_dir.iterdir() if (path / "server.conf").is_file()): subprocess.run( [ - "bash", - str(root_dir / "scripts/deploy-repo.sh"), + "python3", + str(root_dir / "scripts/deploy_repo.py"), "--repo-url", str(repo_dir), "--dest", diff --git a/server.conf b/server.conf index d34e3f2..43a3917 100644 --- a/server.conf +++ b/server.conf @@ -3,7 +3,7 @@ "domain": "server-setup.local", "build_output": ".", "deploy_hooks": { - "build": "./scripts/run-self-checks.sh" + "build": "python3 ./scripts/run_self_checks.py" }, "runtime": { "mode": "static" diff --git a/tests/test_docker_sandbox.sh b/tests/test_docker_sandbox.sh index d33ac87..0676487 100755 --- a/tests/test_docker_sandbox.sh +++ b/tests/test_docker_sandbox.sh @@ -90,8 +90,8 @@ test_docker_sandbox_deploys_tlm_deutschland() { docker exec "$CONTAINER_NAME" bash -lc 'systemctl start nginx' docker exec "$CONTAINER_NAME" bash -lc ' cd /opt/server-setup - ./scripts/prepare-server.sh --email admin@example.com --skip-docker - ./scripts/deploy-repo.sh --repo-url https://github.com/moritzbrantner/tlm-deutschland.git --dest /root/apps/tlm-deutschland --email admin@example.com --skip-github-hook + python3 ./scripts/prepare_server.py --email admin@example.com --skip-docker + python3 ./scripts/deploy_repo.py --repo-url https://github.com/moritzbrantner/tlm-deutschland.git --dest /root/apps/tlm-deutschland --email admin@example.com --skip-github-hook ' docker exec "$CONTAINER_NAME" bash -lc 'test -f /root/apps/tlm-deutschland/.next/BUILD_ID' diff --git a/tests/test_harden_server.py b/tests/test_harden_server.py new file mode 100644 index 0000000..5c67f63 --- /dev/null +++ b/tests/test_harden_server.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import importlib.util +import pathlib +import sys +import unittest +from unittest.mock import patch + +ROOT_DIR = pathlib.Path(__file__).resolve().parents[1] + + +def load_module(): + path = ROOT_DIR / "scripts" / "harden_server.py" + spec = importlib.util.spec_from_file_location("harden_server", path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class HardenServerTests(unittest.TestCase): + def setUp(self) -> None: + self.module = load_module() + + def test_default_run_skips_ssh_configuration(self) -> None: + with patch.object(self.module, "require_root"), patch.object(self.module.shutil, "which", return_value="/usr/bin/apt-get"): + with patch.object(self.module, "configure_unattended_upgrades"), patch.object(self.module, "configure_fail2ban"), patch.object(self.module, "configure_ufw"): + with patch.object(self.module, "ensure_safe_to_disable_password_auth") as ensure_safe, patch.object( + self.module, "write_sshd_hardening_config" + ) as write_sshd, patch.object(self.module, "run_checked"): + with patch.object(sys, "argv", ["harden_server.py"]): + self.module.main() + + ensure_safe.assert_not_called() + write_sshd.assert_not_called() + + def test_configure_ssh_flag_enables_ssh_configuration(self) -> None: + with patch.object(self.module, "require_root"), patch.object(self.module.shutil, "which", return_value="/usr/bin/apt-get"): + with patch.object(self.module, "configure_unattended_upgrades"), patch.object(self.module, "configure_fail2ban"), patch.object(self.module, "configure_ufw"): + with patch.object(self.module, "ensure_safe_to_disable_password_auth") as ensure_safe, patch.object( + self.module, "write_sshd_hardening_config" + ) as write_sshd, patch.object(self.module, "run_checked"): + with patch.object(sys, "argv", ["harden_server.py", "--configure-ssh"]): + self.module.main() + + ensure_safe.assert_called_once() + write_sshd.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_prepare_server.py b/tests/test_prepare_server.py index 4616418..65995ca 100644 --- a/tests/test_prepare_server.py +++ b/tests/test_prepare_server.py @@ -34,6 +34,7 @@ def test_with_status_webapp_runs_status_webapp_installer(self) -> None: email="ops@example.com", skip_docker=True, skip_hardening=True, + with_ssh_hardening=False, with_status_webapp=True, ) @@ -48,8 +49,8 @@ def test_with_status_webapp_runs_status_webapp_installer(self) -> None: run_checked.assert_has_calls( [ - call(["bash", str(root / "scripts/ensure-server-tools.sh"), "--skip-docker"], cwd=root), - call(["bash", str(root / "scripts/setup-status-webapp.sh"), "--root", str(root)], cwd=root), + call(["python3", str(root / "scripts/ensure_server_tools.py"), "--skip-docker"], cwd=root), + call(["python3", str(root / "scripts/setup_status_webapp.py"), "--root", str(root)], cwd=root), ] ) @@ -60,6 +61,7 @@ def test_without_status_webapp_skips_status_webapp_installer(self) -> None: email="ops@example.com", skip_docker=True, skip_hardening=True, + with_ssh_hardening=False, with_status_webapp=False, ) @@ -72,9 +74,63 @@ def test_without_status_webapp_skips_status_webapp_installer(self) -> None: with patch.object(self.module, "run_checked") as run_checked: self.module.main() - status_installer = ["bash", str(root / "scripts/setup-status-webapp.sh"), "--root", str(root)] + status_installer = ["python3", str(root / "scripts/setup_status_webapp.py"), "--root", str(root)] self.assertNotIn(call(status_installer, cwd=root), run_checked.mock_calls) + def test_default_hardening_keeps_ssh_unchanged(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + args = argparse.Namespace( + email="ops@example.com", + skip_docker=True, + skip_hardening=False, + with_ssh_hardening=False, + with_status_webapp=False, + ) + + with patch.object(self.module, "parse_args", return_value=args): + with patch.object(self.module, "require_root"): + with patch.object(self.module, "repo_root", return_value=root): + with patch.object(self.module, "load_env_file", return_value={}): + with patch.object(self.module, "setup_automation_units"): + with patch.object(self.module, "update_env_file"): + with patch.object(self.module, "run_checked") as run_checked: + self.module.main() + + self.assertIn( + call(["python3", str(root / "scripts/harden_server.py")], cwd=root), + run_checked.mock_calls, + ) + self.assertNotIn( + call(["python3", str(root / "scripts/harden_server.py"), "--configure-ssh"], cwd=root), + run_checked.mock_calls, + ) + + def test_explicit_ssh_hardening_passes_opt_in_flag(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + args = argparse.Namespace( + email="ops@example.com", + skip_docker=True, + skip_hardening=False, + with_ssh_hardening=True, + with_status_webapp=False, + ) + + with patch.object(self.module, "parse_args", return_value=args): + with patch.object(self.module, "require_root"): + with patch.object(self.module, "repo_root", return_value=root): + with patch.object(self.module, "load_env_file", return_value={}): + with patch.object(self.module, "setup_automation_units"): + with patch.object(self.module, "update_env_file"): + with patch.object(self.module, "run_checked") as run_checked: + self.module.main() + + self.assertIn( + call(["python3", str(root / "scripts/harden_server.py"), "--configure-ssh"], cwd=root), + run_checked.mock_calls, + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_python_entrypoints.sh b/tests/test_python_entrypoints.sh index d396d5b..64fc436 100644 --- a/tests/test_python_entrypoints.sh +++ b/tests/test_python_entrypoints.sh @@ -28,31 +28,11 @@ test_manage_services_python_entrypoint_shows_help() { python3 "$ROOT_DIR/scripts/manage_services.py" --help >/dev/null } -test_prepare_server_shell_wrapper_shows_help() { - bash "$ROOT_DIR/scripts/prepare-server.sh" --help >/dev/null -} - -test_deploy_repo_shell_wrapper_shows_help() { - bash "$ROOT_DIR/scripts/deploy-repo.sh" --help >/dev/null -} - -test_shutdown_server_shell_wrapper_shows_help() { - bash "$ROOT_DIR/scripts/shutdown-server.sh" --help >/dev/null -} - -test_manage_services_shell_wrapper_shows_help() { - bash "$ROOT_DIR/scripts/manage-services.sh" --help >/dev/null -} - run_test "shutdown_websites.py help works" test_shutdown_websites_python_entrypoint_shows_help run_test "reset_server_setup.py help works" test_reset_server_setup_python_entrypoint_shows_help run_test "prepare_server.py help works" test_prepare_server_python_entrypoint_shows_help run_test "deploy_repo.py help works" test_deploy_repo_python_entrypoint_shows_help run_test "shutdown_server.py help works" test_shutdown_server_python_entrypoint_shows_help run_test "manage_services.py help works" test_manage_services_python_entrypoint_shows_help -run_test "prepare-server.sh help works" test_prepare_server_shell_wrapper_shows_help -run_test "deploy-repo.sh help works" test_deploy_repo_shell_wrapper_shows_help -run_test "shutdown-server.sh help works" test_shutdown_server_shell_wrapper_shows_help -run_test "manage-services.sh help works" test_manage_services_shell_wrapper_shows_help echo "All tests passed: $pass_count" diff --git a/tests/test_seed_example_repositories.sh b/tests/test_seed_example_repositories.sh index 4ede6c9..7713db7 100755 --- a/tests/test_seed_example_repositories.sh +++ b/tests/test_seed_example_repositories.sh @@ -8,13 +8,11 @@ source "$SCRIPT_DIR/lib/test-helpers.sh" # Initialized by test-helpers.sh; repeated here so ShellCheck sees it. declare -i pass_count="${pass_count:-0}" -SEED_SCRIPT="$ROOT_DIR/scripts/seed-example-repositories.sh" - test_seed_examples_creates_expected_git_repositories() { local tmp tmp="$(make_temp_dir)" - "$SEED_SCRIPT" --source-dir "$ROOT_DIR/examples/repositories" --target-dir "$tmp/apps" >"$tmp/seed.log" + python3 "$ROOT_DIR/scripts/seed_example_repositories.py" --source-dir "$ROOT_DIR/examples/repositories" --target-dir "$tmp/apps" >"$tmp/seed.log" assert_eq "3" "$(find "$tmp/apps" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" @@ -32,8 +30,8 @@ test_seed_examples_skips_existing_repositories_without_force() { local tmp tmp="$(make_temp_dir)" - "$SEED_SCRIPT" --source-dir "$ROOT_DIR/examples/repositories" --target-dir "$tmp/apps" >/dev/null - "$SEED_SCRIPT" --source-dir "$ROOT_DIR/examples/repositories" --target-dir "$tmp/apps" >"$tmp/seed.log" + python3 "$ROOT_DIR/scripts/seed_example_repositories.py" --source-dir "$ROOT_DIR/examples/repositories" --target-dir "$tmp/apps" >/dev/null + python3 "$ROOT_DIR/scripts/seed_example_repositories.py" --source-dir "$ROOT_DIR/examples/repositories" --target-dir "$tmp/apps" >"$tmp/seed.log" grep -q "Skipped existing example repository: simple-site" "$tmp/seed.log" grep -q "Skipped existing example repository: rest-api" "$tmp/seed.log" @@ -46,7 +44,7 @@ test_seed_examples_include_server_conf_contract() { local tmp tmp="$(make_temp_dir)" - "$SEED_SCRIPT" --source-dir "$ROOT_DIR/examples/repositories" --target-dir "$tmp/apps" >/dev/null + python3 "$ROOT_DIR/scripts/seed_example_repositories.py" --source-dir "$ROOT_DIR/examples/repositories" --target-dir "$tmp/apps" >/dev/null jq -e '.runtime.mode == "service"' "$tmp/apps/complex-site/server.conf" >/dev/null jq -e '.runtime.mode == "service"' "$tmp/apps/rest-api/server.conf" >/dev/null diff --git a/tests/test_setup_letsencrypt.py b/tests/test_setup_letsencrypt.py new file mode 100644 index 0000000..d39269f --- /dev/null +++ b/tests/test_setup_letsencrypt.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import importlib.util +import pathlib +import sys +import unittest +from unittest.mock import patch + +ROOT_DIR = pathlib.Path(__file__).resolve().parents[1] + + +def load_module(): + path = ROOT_DIR / "scripts" / "setup_letsencrypt.py" + spec = importlib.util.spec_from_file_location("setup_letsencrypt", path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class SetupLetsEncryptTests(unittest.TestCase): + def setUp(self) -> None: + self.module = load_module() + + def test_existing_certificate_path_uses_idempotent_certbot_flags(self) -> None: + commands: list[list[str]] = [] + + def capture_run_checked(cmd: list[str], env=None, allow_fail: bool = False): + commands.append(cmd) + + class Result: + returncode = 0 + stdout = "" + stderr = "" + + return Result() + + with patch.object(sys, "argv", ["setup_letsencrypt.py", "--domain", "example.com", "--email", "ops@example.com", "--www"]): + with patch.object(self.module, "require_root"), patch.object(self.module, "run_checked", side_effect=capture_run_checked): + self.module.main() + + certbot_cmd = next(cmd for cmd in commands if cmd and cmd[0] == "certbot" and "--nginx" in cmd) + self.assertIn("--keep-until-expiring", certbot_cmd) + self.assertIn("--expand", certbot_cmd) + self.assertIn("--cert-name", certbot_cmd) + self.assertIn("example.com", certbot_cmd) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_setup_status_webapp.py b/tests/test_setup_status_webapp.py new file mode 100644 index 0000000..0e59398 --- /dev/null +++ b/tests/test_setup_status_webapp.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import importlib.util +import pathlib +import sys +import tempfile +import unittest + +ROOT_DIR = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT_DIR / "scripts")) + + +def load_module(): + path = ROOT_DIR / "scripts" / "setup_status_webapp.py" + spec = importlib.util.spec_from_file_location("setup_status_webapp", path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class SetupStatusWebappTests(unittest.TestCase): + def setUp(self) -> None: + self.module = load_module() + + def test_write_status_webapp_env_preserves_existing_admin_token(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + env_file = pathlib.Path(tmp) / "server-setup-status-webapp" + env_file.write_text( + "SERVER_SETUP_ROOT=/old/root\n" + "BUN_INSTALL=/old/bun\n" + "STATUS_WEBAPP_HOST=127.0.0.1\n" + "STATUS_WEBAPP_PORT=9999\n" + "STATUS_WEBAPP_ADMIN_TOKEN=keep-me\n", + encoding="utf-8", + ) + + self.module.write_status_webapp_env(env_file, "/new/root", "0.0.0.0", "4000") + + body = env_file.read_text(encoding="utf-8") + + self.assertIn("SERVER_SETUP_ROOT=/new/root\n", body) + self.assertIn("BUN_INSTALL=/root/.bun\n", body) + self.assertIn("STATUS_WEBAPP_HOST=0.0.0.0\n", body) + self.assertIn("STATUS_WEBAPP_PORT=4000\n", body) + self.assertIn("STATUS_WEBAPP_ADMIN_TOKEN=keep-me\n", body) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_setup_status_webapp.sh b/tests/test_setup_status_webapp.sh index c71e2de..569ccdd 100755 --- a/tests/test_setup_status_webapp.sh +++ b/tests/test_setup_status_webapp.sh @@ -26,7 +26,7 @@ test_render_status_webapp_service_restarts_on_failure() { grep -Fq 'EnvironmentFile=-/etc/default/server-setup-status-webapp' <<<"$unit" grep -Fq 'Environment=BUN_INSTALL=/root/.bun' <<<"$unit" grep -Fq "WorkingDirectory=$ROOT_DIR/monitor/webapp" <<<"$unit" - grep -Fq "ExecStart=/usr/bin/env bash $ROOT_DIR/scripts/start-status-webapp.sh" <<<"$unit" + grep -Fq "ExecStart=/usr/bin/env python3 $ROOT_DIR/scripts/start_status_webapp.py" <<<"$unit" grep -Fq 'Environment=STATUS_WEBAPP_PORT=4000' <<<"$unit" grep -Fq 'Restart=always' <<<"$unit" }