@arcote.tech/arc-cli 0.6.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/index.js +1696 -1663
  2. package/package.json +7 -7
  3. package/src/builder/access-extractor.ts +64 -46
  4. package/src/builder/build-cache.ts +3 -1
  5. package/src/builder/chunk-planner.ts +107 -0
  6. package/src/builder/dependency-collector.ts +83 -41
  7. package/src/builder/framework-peers.ts +81 -0
  8. package/src/builder/module-builder.ts +322 -106
  9. package/src/commands/platform-build.ts +2 -1
  10. package/src/commands/platform-deploy.ts +121 -64
  11. package/src/commands/platform-dev.ts +11 -100
  12. package/src/commands/platform-start.ts +4 -90
  13. package/src/deploy/ansible.ts +23 -3
  14. package/src/deploy/assets/ansible/site.yml +23 -7
  15. package/src/deploy/assets.ts +23 -7
  16. package/src/deploy/bootstrap.ts +270 -10
  17. package/src/deploy/caddyfile.ts +19 -23
  18. package/src/deploy/compose.ts +44 -27
  19. package/src/deploy/config.ts +67 -3
  20. package/src/deploy/deploy-env.ts +129 -0
  21. package/src/deploy/env-file.ts +103 -0
  22. package/src/deploy/htpasswd.ts +28 -0
  23. package/src/deploy/image-template.ts +74 -0
  24. package/src/deploy/image.ts +243 -0
  25. package/src/deploy/registry.ts +79 -0
  26. package/src/deploy/ssh.ts +52 -122
  27. package/src/deploy/survey.ts +64 -0
  28. package/src/index.ts +20 -13
  29. package/src/platform/server.ts +119 -94
  30. package/src/platform/shared.ts +139 -292
  31. package/src/platform/startup.ts +159 -0
  32. package/runtime/Dockerfile +0 -29
  33. package/runtime/build-and-push.sh +0 -23
  34. package/runtime/entrypoint.sh +0 -58
  35. package/src/commands/build-shell.ts +0 -152
  36. package/src/deploy/remote-sync.ts +0 -321
  37. package/src/platform/deploy-api.ts +0 -400
@@ -1,13 +1,16 @@
1
1
  import { existsSync, readFileSync } from "fs";
2
2
  import { dirname, join } from "path";
3
+ import { fileURLToPath } from "url";
3
4
  import { bootstrap } from "../deploy/bootstrap";
4
5
  import {
5
6
  deployConfigExists,
6
7
  loadDeployConfig,
7
8
  saveDeployConfig,
8
9
  } from "../deploy/config";
10
+ import { updateEnvDeployment } from "../deploy/deploy-env";
11
+ import { buildImage, sanitizeImageName } from "../deploy/image";
9
12
  import { detectRemoteState } from "../deploy/remote-state";
10
- import { syncEnv } from "../deploy/remote-sync";
13
+ import { dockerLogin, dockerPush } from "../deploy/registry";
11
14
  import { runSurvey } from "../deploy/survey";
12
15
  import {
13
16
  buildAll,
@@ -15,6 +18,7 @@ import {
15
18
  log,
16
19
  ok,
17
20
  resolveWorkspace,
21
+ type WorkspaceInfo,
18
22
  } from "../platform/shared";
19
23
 
20
24
  interface PlatformDeployOptions {
@@ -24,17 +28,34 @@ interface PlatformDeployOptions {
24
28
  skipBuild?: boolean;
25
29
  /** Force rebuild before deploy. */
26
30
  rebuild?: boolean;
31
+ /** Build the Docker image locally, then exit. Does NOT touch the remote. */
32
+ buildOnly?: boolean;
33
+ /**
34
+ * Rollback / pin to a specific image tag. Skips build + push, only updates
35
+ * /opt/arc/.env on the host and triggers `docker compose pull/up`.
36
+ * Format: bare content hash (e.g. `abc123def456`) or full ref.
37
+ */
38
+ imageTag?: string;
39
+ /**
40
+ * Force the Ansible host-bootstrap step to run even when the marker says
41
+ * the host is already configured. Default behavior skips Ansible whenever
42
+ * the server is reachable and has Docker — use this after editing the
43
+ * embedded playbook or to recover from a corrupted host config.
44
+ */
45
+ forceBootstrap?: boolean;
27
46
  }
28
47
 
29
48
  // ---------------------------------------------------------------------------
30
- // Entry point for `arc platform deploy [env] [--skip-build] [--rebuild]`.
49
+ // Entry point for `arc platform deploy [env]`.
31
50
  //
32
- // High-level flow:
51
+ // Flow:
33
52
  // 1. resolveWorkspace
34
- // 2. load or survey deploy.arc.json
35
- // 3. ensure local build (buildAll unless --skip-build)
36
- // 4. detectRemoteState bootstrap if needed
37
- // 5. for each env (or the one passed as arg): syncEnv
53
+ // 2. Load or survey deploy.arc.json
54
+ // 3. Ensure local build (buildAll unless --skip-build)
55
+ // 4. Build Docker image (or accept --image-tag for rollback)
56
+ // 5. dockerLogin + dockerPush
57
+ // 6. Detect remote state → bootstrap if needed
58
+ // 7. For each env: updateEnvDeployment (atomic .env line + pull + up + health)
38
59
  // ---------------------------------------------------------------------------
39
60
 
40
61
  export async function platformDeploy(
@@ -63,59 +84,92 @@ export async function platformDeploy(
63
84
  })()
64
85
  : Object.keys(cfg.envs);
65
86
 
66
- // 2. Ensure local build
67
- const manifestPath = join(ws.modulesDir, "manifest.json");
68
- const needBuild = options.rebuild || !existsSync(manifestPath);
69
- if (needBuild && !options.skipBuild) {
70
- log("Building platform...");
71
- await buildAll(ws, { noCache: options.rebuild });
72
- ok("Build complete");
73
- } else if (!existsSync(manifestPath)) {
74
- err("No build found and --skip-build was set.");
75
- process.exit(1);
87
+ // 2. Ensure local build (unless --image-tag rollback skips build+push entirely)
88
+ const manifestPath = join(ws.arcDir, "manifest.json");
89
+ if (!options.imageTag) {
90
+ const needBuild = options.rebuild || !existsSync(manifestPath);
91
+ if (needBuild && !options.skipBuild) {
92
+ log("Building platform...");
93
+ await buildAll(ws, { noCache: options.rebuild });
94
+ ok("Build complete");
95
+ } else if (!existsSync(manifestPath)) {
96
+ err("No build found and --skip-build was set.");
97
+ process.exit(1);
98
+ }
99
+ }
100
+
101
+ // 3. Build the image locally. Push happens AFTER bootstrap so the registry
102
+ // container exists when we try to push to it (chicken-and-egg otherwise).
103
+ const imageName = sanitizeImageName(ws.rootPkg.name ?? ws.appName);
104
+ let fullRef: string;
105
+ let contentHash: string;
106
+
107
+ if (options.imageTag) {
108
+ contentHash = options.imageTag.includes(":")
109
+ ? options.imageTag.split(":").pop()!
110
+ : options.imageTag;
111
+ fullRef = `${cfg.registry.domain}/${imageName}:${contentHash}`;
112
+ log(`Pinning to existing image ${fullRef} (skipping build + push)`);
113
+ } else {
114
+ log(`Building Docker image ${imageName}...`);
115
+ const result = await buildImage(ws, {
116
+ imageName,
117
+ registryDomain: cfg.registry.domain,
118
+ });
119
+ fullRef = result.fullRef;
120
+ contentHash = result.contentHash;
121
+ ok(`Image built: ${fullRef}`);
122
+
123
+ // 3b. --build-only: produce image, log, exit before push/deploy.
124
+ if (options.buildOnly) {
125
+ log(`contentHash: ${contentHash}`);
126
+ return;
127
+ }
76
128
  }
77
129
 
78
- // 3. Detect remote state
130
+ // 4. Detect remote state + bootstrap. This brings up caddy + registry on
131
+ // first deploy (and regenerates the stack if config changed). MUST run
132
+ // before dockerLogin/dockerPush — without registry container + Caddy vhost
133
+ // for it, dockerLogin would TLS-fail.
79
134
  log("Inspecting remote server...");
80
135
  const state = await detectRemoteState(cfg);
81
136
  log(`Remote state: ${state.kind}`);
82
137
 
83
- // 4. Bootstrap if needed
84
138
  const cliVersion = readCliVersion();
85
139
  const configHash = await hashDeployConfig(ws.rootDir);
86
- if (state.kind !== "ready") {
87
- await bootstrap({
88
- cfg,
89
- rootDir: ws.rootDir,
90
- state,
91
- cliVersion,
92
- configHash,
93
- });
140
+ await bootstrap({
141
+ cfg,
142
+ rootDir: ws.rootDir,
143
+ state,
144
+ cliVersion,
145
+ configHash,
146
+ forceAnsible: options.forceBootstrap,
147
+ });
148
+
149
+ // 5. Push the image to the now-running registry.
150
+ if (!options.imageTag) {
151
+ log(`Logging in to ${cfg.registry.domain}...`);
152
+ await dockerLogin(cfg.registry);
153
+ log(`Pushing ${fullRef}...`);
154
+ await dockerPush(fullRef);
155
+ ok("Image pushed");
94
156
  }
95
157
 
96
- // 5. Sync each env
158
+ // 6. Update each env — atomic /opt/arc/.env line + pull + up + health
97
159
  for (const env of targetEnvs) {
98
- log(`Syncing env "${env}"...`);
99
- const outcome = await syncEnv({
160
+ log(`Updating env "${env}"...`);
161
+ const outcome = await updateEnvDeployment({
162
+ target: cfg.target,
100
163
  cfg,
101
164
  env,
102
- ws,
103
- projectDir: ws.rootDir,
165
+ fullRef,
104
166
  });
105
- if (
106
- outcome.changedModules.length === 0 &&
107
- !outcome.shellChanged &&
108
- !outcome.stylesChanged
109
- ) {
110
- ok(`${env}: already up to date`);
167
+ if (outcome.redeployed) {
168
+ ok(`${env}: live at ${fullRef}`);
111
169
  } else {
112
- const parts: string[] = [];
113
- if (outcome.changedModules.length > 0) {
114
- parts.push(`${outcome.changedModules.length} module(s): ${outcome.changedModules.join(", ")}`);
115
- }
116
- if (outcome.shellChanged) parts.push("shell");
117
- if (outcome.stylesChanged) parts.push("styles");
118
- ok(`${env}: updated ${parts.join(", ")}`);
170
+ err(
171
+ `${env}: deployed but health check did not pass within retries — check \`docker logs arc-${env}\``,
172
+ );
119
173
  }
120
174
  }
121
175
  }
@@ -124,30 +178,33 @@ export async function platformDeploy(
124
178
  // Helpers
125
179
  // ---------------------------------------------------------------------------
126
180
 
181
+ /**
182
+ * Read the arc-cli package version by walking up from this file until we
183
+ * find a package.json with `name: "@arcote.tech/arc-cli"`. Source and
184
+ * bundled layouts have different depths (source: src/commands/, bundle:
185
+ * dist/), so a fixed `..` count doesn't work — walk until we hit the
186
+ * canonical manifest.
187
+ */
127
188
  function readCliVersion(): string {
128
- // import.meta.dir gets mangled by `bun build` — derive from process.argv[1]
129
- // (the bundled dist/index.js path) which is stable across run modes.
130
- const candidates: string[] = [];
131
- const entry = process.argv[1];
132
- if (entry) {
133
- candidates.push(join(dirname(entry), "..", "package.json"));
134
- }
135
189
  try {
136
- candidates.push(join(import.meta.dir, "..", "..", "package.json"));
137
- } catch {
138
- // import.meta.dir unavailable
139
- }
140
- for (const path of candidates) {
141
- try {
142
- const pkg = JSON.parse(readFileSync(path, "utf-8"));
143
- if (pkg.name === "@arcote.tech/arc-cli" && pkg.version) {
144
- return pkg.version as string;
190
+ let cur = dirname(fileURLToPath(import.meta.url));
191
+ const root = dirname(cur).startsWith("/") ? "/" : ".";
192
+ while (cur !== root && cur !== "") {
193
+ const candidate = join(cur, "package.json");
194
+ if (existsSync(candidate)) {
195
+ const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
196
+ if (pkg.name === "@arcote.tech/arc-cli") {
197
+ return pkg.version ?? "unknown";
198
+ }
145
199
  }
146
- } catch {
147
- // Try next
200
+ const parent = dirname(cur);
201
+ if (parent === cur) break;
202
+ cur = parent;
148
203
  }
204
+ return "unknown";
205
+ } catch {
206
+ return "unknown";
149
207
  }
150
- return "unknown";
151
208
  }
152
209
 
153
210
  async function hashDeployConfig(rootDir: string): Promise<string> {
@@ -1,103 +1,14 @@
1
- import { existsSync, watch } from "fs";
2
- import { join } from "path";
3
- import { startPlatformServer } from "../platform/server";
4
- import {
5
- buildAll,
6
- collectArcPeerDeps,
7
- loadServerContext,
8
- log,
9
- ok,
10
- resolveWorkspace,
11
- } from "../platform/shared";
1
+ import { startPlatform } from "../platform/startup";
2
+ import { resolveWorkspace } from "../platform/shared";
12
3
 
13
- export async function platformDev(opts: { noCache?: boolean } = {}): Promise<void> {
4
+ /** `arc platform dev` — dev mode (watcher + SSE reload + no-cache headers). */
5
+ export async function platformDev(
6
+ opts: { noCache?: boolean } = {},
7
+ ): Promise<void> {
14
8
  const ws = resolveWorkspace();
15
- const port = 5005;
16
-
17
- // Initial build — `--no-cache` (if passed) only forces the startup pass;
18
- // subsequent rebuilds always respect the cache to keep dev incremental.
19
- let manifest = await buildAll(ws, { noCache: opts.noCache });
20
-
21
- log("Loading server context...");
22
- const { context, moduleAccess } = await loadServerContext(ws.packages);
23
- if (context) {
24
- ok("Context loaded");
25
- } else {
26
- log("No context — server endpoints skipped");
27
- }
28
-
29
- const arcEntries = collectArcPeerDeps(ws.packages);
30
- const platform = await startPlatformServer({
31
- ws,
32
- port,
33
- manifest,
34
- context,
35
- moduleAccess,
36
- dbPath: join(ws.rootDir, ".arc", "data", "dev.db"),
37
- devMode: true,
38
- arcEntries,
39
- });
40
-
41
- ok(`Server on http://localhost:${port}`);
42
- if (platform.contextHandler) ok("Commands, queries, WebSocket — all on same port");
43
-
44
- // Watch for changes — full buildAll on debounce; cache makes it cheap when
45
- // only one package changed.
46
- log("Watching for changes...");
47
- let rebuildTimer: ReturnType<typeof setTimeout> | null = null;
48
- let isRebuilding = false;
49
-
50
- const triggerRebuild = () => {
51
- if (rebuildTimer) clearTimeout(rebuildTimer);
52
- rebuildTimer = setTimeout(async () => {
53
- if (isRebuilding) return;
54
- isRebuilding = true;
55
- log("Rebuilding...");
56
- try {
57
- manifest = await buildAll(ws);
58
- platform.setManifest(manifest);
59
- platform.notifyReload(manifest);
60
- ok(`Rebuilt — ${manifest.modules.length} module(s)`);
61
- } catch (e) {
62
- console.error(`Rebuild failed: ${e}`);
63
- } finally {
64
- isRebuilding = false;
65
- }
66
- }, 300);
67
- };
68
-
69
- for (const pkg of ws.packages) {
70
- const srcDir = join(pkg.path, "src");
71
- if (!existsSync(srcDir)) continue;
72
-
73
- watch(srcDir, { recursive: true }, (_event, filename) => {
74
- if (
75
- !filename ||
76
- filename.includes(".arc") ||
77
- filename.endsWith(".d.ts") ||
78
- filename.includes("node_modules") ||
79
- filename.includes("dist")
80
- ) return;
81
-
82
- if (!filename.endsWith(".ts") && !filename.endsWith(".tsx")) return;
83
-
84
- triggerRebuild();
85
- });
86
- }
87
-
88
- // .po files — trigger rebuild so the `translations` cache unit picks them up.
89
- const localesDir = join(ws.rootDir, "locales");
90
- if (existsSync(localesDir)) {
91
- watch(localesDir, { recursive: false }, (_event, filename) => {
92
- if (!filename?.endsWith(".po")) return;
93
- triggerRebuild();
94
- });
95
- }
96
-
97
- const cleanup = () => {
98
- platform.stop();
99
- process.exit(0);
100
- };
101
- process.on("SIGTERM", cleanup);
102
- process.on("SIGINT", cleanup);
9
+ // noCache is consumed by buildAll inside startPlatform when devMode=true.
10
+ // For now the dev startup always uses the cache after the first build;
11
+ // explicit --no-cache here only matters if we wire it through later.
12
+ void opts;
13
+ await startPlatform({ ws, devMode: true });
103
14
  }
@@ -1,94 +1,8 @@
1
- import { existsSync, readFileSync } from "fs";
2
- import { join } from "path";
3
- import { startPlatformServer } from "../platform/server";
4
- import {
5
- collectArcPeerDeps,
6
- err,
7
- loadServerContext,
8
- log,
9
- ok,
10
- resolveWorkspace,
11
- type BuildManifest,
12
- } from "../platform/shared";
1
+ import { startPlatform } from "../platform/startup";
2
+ import { resolveWorkspace } from "../platform/shared";
13
3
 
4
+ /** `arc platform start` — production mode (no watcher, immutable cache). */
14
5
  export async function platformStart(): Promise<void> {
15
6
  const ws = resolveWorkspace();
16
- const port = parseInt(process.env.PORT || "5005", 10);
17
- const deployApi = process.env.ARC_DEPLOY_API === "1";
18
-
19
- // Pre-deploy mode: container started with empty volume (first boot of an
20
- // arcote/runtime container — manifest hasn't been pushed yet). Boot a
21
- // minimal server so the deploy CLI can reach /api/deploy/* to push the
22
- // initial framework + modules. Container restart (after first manifest
23
- // commit) re-enters this function with manifest present → full mode.
24
- const manifestPath = join(ws.modulesDir, "manifest.json");
25
- if (!existsSync(manifestPath)) {
26
- if (!deployApi) {
27
- err("No build found. Run `arc platform build` first.");
28
- process.exit(1);
29
- }
30
- log("Pre-deploy mode — no manifest yet, awaiting first /api/deploy/*");
31
- const emptyManifest: BuildManifest = {
32
- modules: [],
33
- shellHash: "",
34
- stylesHash: "",
35
- buildTime: new Date().toISOString(),
36
- };
37
- const platform = await startPlatformServer({
38
- ws,
39
- port,
40
- manifest: emptyManifest,
41
- context: null,
42
- moduleAccess: new Map(),
43
- dbPath: join(ws.rootDir, ".arc", "data", "prod.db"),
44
- devMode: false,
45
- deployApi: true,
46
- arcEntries: [],
47
- });
48
- ok(`Pre-deploy server on http://localhost:${port}`);
49
- registerSignalCleanup(platform);
50
- return;
51
- }
52
-
53
- const manifest: BuildManifest = JSON.parse(
54
- readFileSync(manifestPath, "utf-8"),
55
- );
56
-
57
- // Load server context
58
- log("Loading server context...");
59
- const { context, moduleAccess } = await loadServerContext(ws.packages);
60
- if (context) {
61
- ok("Context loaded");
62
- } else {
63
- log("No context — server endpoints skipped");
64
- }
65
-
66
- // Start server (production mode = aggressive caching, SSE only used for deploy hot-swap)
67
- const arcEntries = collectArcPeerDeps(ws.packages);
68
- if (deployApi) ok("Deploy API enabled (/api/deploy/*)");
69
- const platform = await startPlatformServer({
70
- ws,
71
- port,
72
- manifest,
73
- context,
74
- moduleAccess,
75
- dbPath: join(ws.rootDir, ".arc", "data", "prod.db"),
76
- devMode: false,
77
- deployApi,
78
- arcEntries,
79
- });
80
-
81
- ok(`Server on http://localhost:${port}`);
82
- if (platform.contextHandler) ok("Commands, queries, WebSocket — all on same port");
83
-
84
- registerSignalCleanup(platform);
85
- }
86
-
87
- function registerSignalCleanup(platform: { stop: () => void }): void {
88
- const cleanup = () => {
89
- platform.stop();
90
- process.exit(0);
91
- };
92
- process.on("SIGTERM", cleanup);
93
- process.on("SIGINT", cleanup);
7
+ await startPlatform({ ws, devMode: false });
94
8
  }
@@ -1,10 +1,24 @@
1
1
  import { spawn as nodeSpawn } from "node:child_process";
2
- import { mkdirSync, writeFileSync } from "fs";
3
- import { tmpdir } from "os";
2
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
3
+ import { homedir, tmpdir } from "os";
4
4
  import { join } from "path";
5
5
  import { ASSETS, materializeAssets } from "./assets";
6
6
  import type { DeployProvisionAnsible, DeployTarget } from "./config";
7
7
 
8
+ function pickSshKeyForAnsible(configured?: string): string | null {
9
+ if (configured) {
10
+ const expanded = configured.startsWith("~")
11
+ ? join(homedir(), configured.slice(1))
12
+ : configured;
13
+ return existsSync(expanded) ? expanded : null;
14
+ }
15
+ for (const name of ["id_ed25519", "id_ecdsa", "id_rsa"]) {
16
+ const path = join(homedir(), ".ssh", name);
17
+ if (existsSync(path)) return path;
18
+ }
19
+ return null;
20
+ }
21
+
8
22
  // ---------------------------------------------------------------------------
9
23
  // Runs Ansible from embedded assets. Inventory is generated on the fly,
10
24
  // targeting the single host described by DeployTarget. Runs as root on the
@@ -27,12 +41,18 @@ export async function runAnsible(inputs: AnsibleInputs): Promise<void> {
27
41
  const user = inputs.asRoot ? "root" : inputs.target.user;
28
42
  const port = inputs.ansible?.sshPort ?? inputs.target.port;
29
43
 
44
+ // IdentitiesOnly=yes + explicit -i prevent ssh from walking every key in
45
+ // ssh-agent (the server's MaxAuthTries=3 trips when the agent holds >3 keys
46
+ // and ours isn't first). PreferredAuthentications=publickey skips gssapi
47
+ // prompts that also count against MaxAuthTries.
48
+ const sshKey = pickSshKeyForAnsible(inputs.target.sshKey);
49
+ const sshKeyArg = sshKey ? ` -o IdentitiesOnly=yes -i ${sshKey}` : "";
30
50
  const inventory = [
31
51
  "[arc]",
32
52
  `${inputs.target.host} ansible_user=${user} ansible_port=${port}`,
33
53
  "",
34
54
  "[arc:vars]",
35
- "ansible_ssh_common_args='-o StrictHostKeyChecking=accept-new -o BatchMode=yes'",
55
+ `ansible_ssh_common_args='-o StrictHostKeyChecking=accept-new -o BatchMode=yes -o PreferredAuthentications=publickey${sshKeyArg}'`,
36
56
  "ansible_python_interpreter=/usr/bin/python3",
37
57
  "",
38
58
  ].join("\n");
@@ -115,7 +115,22 @@
115
115
  - { policy: deny, dir: incoming }
116
116
  - { policy: allow, dir: outgoing }
117
117
 
118
- - name: Open firewall ports
118
+ - name: Remove legacy ufw limit rule on SSH (replaced by plain allow)
119
+ # If a prior bootstrap installed `ufw limit 22/tcp`, drop it — otherwise
120
+ # the limit rule shadows the allow rule and rate-throttles deploy flows.
121
+ ufw:
122
+ rule: limit
123
+ port: "{{ ssh_port }}"
124
+ proto: tcp
125
+ delete: true
126
+ ignore_errors: true
127
+
128
+ - name: Open firewall ports (SSH key-only auth, no brute-force surface)
129
+ # SSH on port 22: PasswordAuthentication=no + key-only means brute force
130
+ # is impossible without the operator's private key. Rate-limiting (ufw
131
+ # limit / fail2ban sshd jail) breaks legitimate deploy flows that open
132
+ # many short SSH connections in sequence (canSsh → sshExec → scp → ...).
133
+ # 80/443: Caddy ACME + app traffic, never rate-limited.
119
134
  ufw:
120
135
  rule: allow
121
136
  port: "{{ item }}"
@@ -129,17 +144,18 @@
129
144
  ufw:
130
145
  state: enabled
131
146
 
132
- - name: Configure fail2ban for sshd
147
+ - name: Disable fail2ban sshd jail
148
+ # Key-only SSH + ufw rate-limit make fail2ban for sshd redundant and
149
+ # actively harmful when the operator's IP roams. Keep fail2ban installed
150
+ # for future jails (web/db) but turn off the sshd jail explicitly.
133
151
  copy:
134
152
  dest: /etc/fail2ban/jail.local
135
153
  content: |
136
154
  [sshd]
137
- enabled = true
138
- port = {{ ssh_port }}
139
- maxretry = 5
140
- findtime = 600
141
- bantime = 3600
155
+ enabled = false
156
+ {% if extra_allowed_ips %}
142
157
  ignoreip = 127.0.0.1/8 ::1 {{ extra_allowed_ips | join(' ') }}
158
+ {% endif %}
143
159
  mode: "0644"
144
160
  notify: restart fail2ban
145
161
 
@@ -201,7 +201,22 @@ const ANSIBLE_SITE_YML = `---
201
201
  - { policy: deny, dir: incoming }
202
202
  - { policy: allow, dir: outgoing }
203
203
 
204
- - name: Open firewall ports
204
+ - name: Remove legacy ufw limit rule on SSH (replaced by plain allow)
205
+ # If a prior bootstrap installed \`ufw limit 22/tcp\`, drop it — otherwise
206
+ # the limit rule shadows the allow rule and rate-throttles deploy flows.
207
+ ufw:
208
+ rule: limit
209
+ port: "{{ ssh_port }}"
210
+ proto: tcp
211
+ delete: true
212
+ ignore_errors: true
213
+
214
+ - name: Open firewall ports (SSH key-only auth, no brute-force surface)
215
+ # SSH on port 22: PasswordAuthentication=no + key-only means brute force
216
+ # is impossible without the operator's private key. Rate-limiting (ufw
217
+ # limit / fail2ban sshd jail) breaks legitimate deploy flows that open
218
+ # many short SSH connections in sequence (canSsh -> sshExec -> scp -> ...).
219
+ # 80/443: Caddy ACME + app traffic, never rate-limited.
205
220
  ufw:
206
221
  rule: allow
207
222
  port: "{{ item }}"
@@ -215,17 +230,18 @@ const ANSIBLE_SITE_YML = `---
215
230
  ufw:
216
231
  state: enabled
217
232
 
218
- - name: Configure fail2ban for sshd
233
+ - name: Disable fail2ban sshd jail
234
+ # Key-only SSH + ufw rate-limit make fail2ban for sshd redundant and
235
+ # actively harmful when the operator's IP roams. Keep fail2ban installed
236
+ # for future jails (web/db) but turn off the sshd jail explicitly.
219
237
  copy:
220
238
  dest: /etc/fail2ban/jail.local
221
239
  content: |
222
240
  [sshd]
223
- enabled = true
224
- port = {{ ssh_port }}
225
- maxretry = 5
226
- findtime = 600
227
- bantime = 3600
241
+ enabled = false
242
+ {% if extra_allowed_ips %}
228
243
  ignoreip = 127.0.0.1/8 ::1 {{ extra_allowed_ips | join(' ') }}
244
+ {% endif %}
229
245
  mode: "0644"
230
246
  notify: restart fail2ban
231
247