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
- The seeding script turns this folder into a standalone git repo under
/srv/apps/simple-site.
- deploy-repo.sh validates its server.conf and writes the generated registry entry.
- deploy-repo.sh publishes it through Nginx and configures webhook redeploys.
+ deploy_repo.py validates its server.conf and writes the generated registry entry.
+ deploy_repo.py publishes it through Nginx and configures webhook redeploys.
Back to the home page
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 {
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"
}