gowalk-cicd 1.0.163 → 1.0.166

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.
package/README.md CHANGED
@@ -210,8 +210,14 @@ it rsyncs the backend dir to `/opt/gowalk-backends/<app>/`, runs
210
210
  wires an nginx vhost + Let's Encrypt cert for the API domain, and health-checks.
211
211
  Hosts with several existing Let's Encrypt accounts select one deterministically,
212
212
  and a domain deploy fails unless its public HTTPS certificate and health route validate.
213
- Changing a container's published port preserves its existing HTTPS configuration. An existing
214
- certificate is installed for the requested domain on redeploy, repairing older HTTP-only vhosts too.
213
+ Changing a container's published port preserves its existing HTTPS configuration; nginx must accept
214
+ the change, or the previous vhost is restored and the deploy fails. An existing certificate is
215
+ installed for the requested domain on redeploy, repairing older HTTP-only vhosts too.
216
+ A redeploy whose vhost already holds that certificate behind Certbot's redirect, whose certificate and
217
+ key form a pair, whose running nginx presents it for the domain, and whose name no other server block in
218
+ nginx's loaded configuration declares, skips the install, which would otherwise save a checkpoint copy of
219
+ every nginx file on the host. It still tests and reloads nginx as the install did, and fails the deploy
220
+ when nginx refuses either.
215
221
 
216
222
  Requirements on the consumer repo:
217
223
 
@@ -225,6 +231,13 @@ Requirements on the consumer repo:
225
231
  - Repo **secret** `BACKEND_RUNTIME_ENV` (optional): newline-delimited `KEY=value`
226
232
  runtime secrets. The action writes it as mode 0600 on the host and passes it
227
233
  to Compose without copying values into the repository or action log.
234
+ Existing quoted values, comments and ordinary dotenv assignments retain their syntax.
235
+ Raw multiline PEM blocks and JSON objects/arrays
236
+ are converted to quoted literal values, preserving embedded dollars and backslashes.
237
+ Already quoted values and single-line JSON retain their existing interpolation semantics.
238
+ Malformed or ambiguous multiline structures refuse before deployment. Transport is bounded
239
+ to 1 MiB of UTF-8 input and 512 KiB per value, uses private SSH stdin, and atomically replaces
240
+ `.runtime.env`; application sync preserves the last installed file if validation fails.
228
241
  - Repo **variables** (optional): `BACKEND_API_DOMAIN` (its DNS A record must point
229
242
  at the host for the cert; empty = deploy the container only, skip nginx/cert),
230
243
  `BACKEND_APP_NAME` (defaults to the repo name), `BACKEND_HEALTH_PATH`
@@ -1 +1 @@
1
- 1.0.163
1
+ 1.0.166
@@ -0,0 +1,119 @@
1
+ /* Close a timed-out iOS upload only after the original owner and Apple both prove completion. */
2
+ 'use strict';
3
+ const fs = require('node:fs');
4
+ const crypto = require('node:crypto');
5
+ const github = require('./mobile_release_api.cjs');
6
+
7
+ const RECEIPT = 'gowalk-cicd/apple-build-upload-receipt.v1';
8
+ const OWNER = 'gowalk-cicd/mobile-upload-owner.v1';
9
+
10
+ function refuse(code) { throw Error(`apple_upload_reconcile_${code}`); }
11
+
12
+ function readReceipt(path) {
13
+ let data;
14
+ try { data = JSON.parse(fs.readFileSync(path, 'utf8')); } catch { refuse('receipt_unavailable'); }
15
+ const id = value => typeof value === 'string' && /^[A-Za-z0-9-]{1,64}$/.test(value);
16
+ if (data?.schema !== RECEIPT || data.stage !== 'processing' ||
17
+ data.failure?.category !== 'processing_pending' ||
18
+ !/^\d+$/.test(data.app_id || '') || !id(data.upload_id) || !id(data.file_id) ||
19
+ !/^[a-z0-9.]+$/.test(data.bundle_id || '') || !/^\d+$/.test(data.cfBundleVersion || '') ||
20
+ !/^\d+(\.\d+){1,2}$/.test(data.cfBundleShortVersionString || '') ||
21
+ !/^[a-f0-9]{64}$/.test(data.sha256 || '') || data.fileName !== `${data.sha256}.ipa` ||
22
+ !Number.isSafeInteger(data.fileSize) || data.fileSize <= 0) refuse('receipt_invalid');
23
+ return data;
24
+ }
25
+
26
+ async function verifyOwner(deploymentId, runId, attempt, api = github) {
27
+ if (!Number.isSafeInteger(deploymentId) || deploymentId <= 0 ||
28
+ !Number.isSafeInteger(runId) || runId <= 0 ||
29
+ !Number.isSafeInteger(attempt) || attempt <= 0) refuse('input_invalid');
30
+ const deployment = await api.request('GET', `/deployments/${deploymentId}`);
31
+ const owner = deployment.payload || {};
32
+ if (deployment.id !== deploymentId || deployment.task !== 'mobile-upload' ||
33
+ !/^mobile-ios-[a-f0-9]{64}$/.test(deployment.environment || '') ||
34
+ owner.schema !== OWNER || owner.platform !== 'ios' ||
35
+ owner.fingerprint !== deployment.environment.slice('mobile-ios-'.length) ||
36
+ owner.run_id !== runId || owner.run_attempt !== attempt ||
37
+ !/^[a-f0-9]{40}$/.test(owner.source_sha || '') || deployment.sha !== owner.source_sha) {
38
+ refuse('owner_invalid');
39
+ }
40
+ const run = await api.request('GET', `/actions/runs/${runId}/attempts/${attempt}`);
41
+ if (run.head_sha !== owner.source_sha || run.path !== '.github/workflows/deploy.yml' ||
42
+ run.event !== 'push' || run.head_branch !== 'main' ||
43
+ run.status !== 'completed' || run.conclusion !== 'failure') refuse('run_invalid');
44
+ const statuses = await api.request('GET', `/deployments/${deploymentId}/statuses?per_page=1`);
45
+ const latest = statuses[0] || {};
46
+ if (latest.state === 'success' && latest.description === 'uploaded') return 'reused';
47
+ if (latest.state !== 'in_progress' || latest.description !== 'uploading') refuse('phase_invalid');
48
+ return 'pending';
49
+ }
50
+
51
+ async function appleGet(path) {
52
+ if (!path.startsWith('/') || path.includes('..')) refuse('path_invalid');
53
+ const now = Math.floor(Date.now() / 1000);
54
+ const encoded = value => Buffer.from(JSON.stringify(value)).toString('base64url');
55
+ const header = encoded({alg: 'ES256', kid: process.env.ASC_KEY_ID, typ: 'JWT'});
56
+ const payload = encoded({iss: process.env.ASC_ISSUER_ID, iat: now, exp: now + 1200,
57
+ aud: 'appstoreconnect-v1'});
58
+ let signature;
59
+ try {
60
+ signature = crypto.sign('sha256', Buffer.from(`${header}.${payload}`),
61
+ {key: process.env.ASC_KEY_P8, dsaEncoding: 'ieee-p1363'}).toString('base64url');
62
+ } catch { refuse('apple_key_invalid'); }
63
+ const response = await fetch(`https://api.appstoreconnect.apple.com/v1${path}`, {
64
+ headers: {Authorization: `Bearer ${header}.${payload}.${signature}`, Accept: 'application/json'},
65
+ signal: AbortSignal.timeout(30000),
66
+ });
67
+ if (!response.ok) refuse(`apple_http_${response.status}`);
68
+ try { return await response.json(); } catch { refuse('apple_response_invalid'); }
69
+ }
70
+
71
+ async function verifyApple(receipt, get = appleGet) {
72
+ const upload = (await get(`/buildUploads/${receipt.upload_id}?include=build`)).data;
73
+ const attributes = upload?.attributes || {};
74
+ const buildId = upload?.relationships?.build?.data?.id;
75
+ if (upload?.id !== receipt.upload_id || attributes.state?.state !== 'COMPLETE' ||
76
+ attributes.cfBundleVersion !== receipt.cfBundleVersion ||
77
+ attributes.cfBundleShortVersionString !== receipt.cfBundleShortVersionString ||
78
+ !/^[A-Za-z0-9-]{1,64}$/.test(buildId || '')) refuse('upload_unverified');
79
+ const file = (await get(`/buildUploadFiles/${receipt.file_id}`)).data;
80
+ if (file?.id !== receipt.file_id || file.attributes?.assetType !== 'ASSET' ||
81
+ file.attributes.fileName !== receipt.fileName ||
82
+ file.attributes.fileSize !== receipt.fileSize ||
83
+ file.attributes.assetDeliveryState?.state !== 'COMPLETE') refuse('file_unverified');
84
+ const build = (await get(`/builds/${buildId}`)).data;
85
+ if (build?.id !== buildId || build.attributes?.processingState !== 'VALID' ||
86
+ build.attributes?.buildAudienceType !== 'APP_STORE_ELIGIBLE' ||
87
+ build.attributes?.version !== receipt.cfBundleVersion) refuse('build_unverified');
88
+ const app = (await get(`/builds/${buildId}/app`)).data;
89
+ if (app?.id !== receipt.app_id || app.attributes?.bundleId !== receipt.bundle_id) refuse('app_unverified');
90
+ return {build_id: buildId, app_id: app.id, version: receipt.cfBundleShortVersionString,
91
+ build: receipt.cfBundleVersion, upload_id: receipt.upload_id};
92
+ }
93
+
94
+ async function reconcile(deploymentId, runId, attempt, path, api = github, get = appleGet) {
95
+ const receipt = readReceipt(path);
96
+ const phase = await verifyOwner(deploymentId, runId, attempt, api);
97
+ const proof = await verifyApple(receipt, get);
98
+ if (phase === 'pending') await api.status(deploymentId, 'uploaded', 'success');
99
+ const statuses = await api.request('GET', `/deployments/${deploymentId}/statuses?per_page=1`);
100
+ if (statuses[0]?.state !== 'success' || statuses[0]?.description !== 'uploaded') {
101
+ refuse('write_unconfirmed');
102
+ }
103
+ return {schema: 'gowalk-cicd/apple-upload-reconciled.v1', state: phase === 'pending' ? 'landed' : 'reused',
104
+ deployment_id: deploymentId, ...proof};
105
+ }
106
+
107
+ async function main() {
108
+ const [deployment, run, attempt, path] = process.argv.slice(2);
109
+ const result = await reconcile(Number(deployment), Number(run), Number(attempt), path);
110
+ console.log(JSON.stringify(result));
111
+ }
112
+
113
+ if (require.main === module) main().catch(error => {
114
+ const code = /^apple_upload_reconcile_[a-z0-9_]+$/.test(error.message) ? error.message :
115
+ 'apple_upload_reconcile_unavailable';
116
+ console.error('::error title=apple_upload_reconcile_refused::' + JSON.stringify({code}));
117
+ process.exitCode = 1;
118
+ });
119
+ module.exports = {readReceipt, verifyOwner, appleGet, verifyApple, reconcile};
@@ -1 +1 @@
1
- 1.0.163
1
+ 1.0.166
@@ -1 +1 @@
1
- 1.0.163
1
+ 1.0.166
@@ -149,17 +149,17 @@ runs:
149
149
  SSH="ssh -i ~/.ssh/backend_deploy -o IdentitiesOnly=yes"
150
150
  DEST="/opt/gowalk-backends/${{ inputs.app-name }}"
151
151
  $SSH "${{ inputs.ssh-user }}@${{ inputs.host }}" "mkdir -p '$DEST'"
152
- # --delete keeps the mirror true; exclude .env so server-side secrets
153
- # (generated DB password, etc.) survive across deploys.
152
+ # --delete keeps the mirror true; preserve the private server-side env files
153
+ # until their replacement validates so failed credential updates retain the old file.
154
154
  if [ "${COMPOSE_FILE%/*}" = "$BACKEND_DIR" ]; then
155
- rsync -az --delete --exclude '.env' --exclude '.git' \
155
+ rsync -az --delete --exclude '.env' --exclude '.runtime.env' --exclude '.git' \
156
156
  --exclude '.backend-edge.json' --exclude '.backend-ingress.conf' --exclude '.backend-public.env' \
157
157
  -e "$SSH" "$BACKEND_DIR/" \
158
158
  "${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/"
159
159
  else
160
160
  $SSH "${{ inputs.ssh-user }}@${{ inputs.host }}" \
161
161
  "mkdir -p '$DEST/$BACKEND_DIR'"
162
- rsync -az --delete --exclude '.env' --exclude '.git' \
162
+ rsync -az --delete --exclude '.env' --exclude '.runtime.env' --exclude '.git' \
163
163
  -e "$SSH" "$BACKEND_DIR/" \
164
164
  "${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/$BACKEND_DIR/"
165
165
  rsync -az -e "$SSH" "$COMPOSE_FILE" \
@@ -171,8 +171,11 @@ runs:
171
171
  "${{ github.action_path }}/remote_deploy.sh" \
172
172
  "${{ github.action_path }}/select_certbot_account.sh" \
173
173
  "${{ github.action_path }}/ensure_certificate.sh" \
174
+ "${{ github.action_path }}/certificate_vhost.py" \
174
175
  "${{ github.action_path }}/configure_vhost.py" \
175
176
  "${{ github.action_path }}/edge_inputs.py" \
177
+ "${{ github.action_path }}/runtime_env.py" \
178
+ "${{ github.action_path }}/runtime_env_parse.py" \
176
179
  "${{ github.action_path }}/edge_vhost.py" \
177
180
  "${{ github.action_path }}/edge_health.py" \
178
181
  "${{ github.action_path }}/identity_diagnostics.py" \
@@ -208,9 +211,9 @@ runs:
208
211
  "${{ inputs.ssh-user }}@${{ inputs.host }}" \
209
212
  "umask 077; mkdir -p '$EDGE_DIR'; python3 '$DEST/edge_inputs.py' receive '$EDGE_DIR/.backend-edge.json'"
210
213
  if [ -n "${RUNTIME_ENV:-}" ]; then
211
- printf '%s\n' "$RUNTIME_ENV" | $SSH \
214
+ python3 "${{ github.action_path }}/runtime_env.py" export | $SSH \
212
215
  "${{ inputs.ssh-user }}@${{ inputs.host }}" \
213
- "umask 077; cat > '$DEST/.runtime.env'"
216
+ "python3 '$DEST/runtime_env.py' receive '$DEST/.runtime.env'"
214
217
  fi
215
218
  BACKEND_DEPLOY_PHASE=remote_deploy
216
219
  $SSH "${{ inputs.ssh-user }}@${{ inputs.host }}" \
@@ -0,0 +1,259 @@
1
+ """Tell whether a domain's vhost already serves its lineage exactly as `certbot install --redirect` leaves it.
2
+
3
+ Every `certbot install --nginx` saves a checkpoint copy of every nginx file on the host, even when it
4
+ changes nothing. The deploy skips that install only when this prints `current`: the managed file holds
5
+ Certbot's TLS block for exactly that lineage behind Certbot's redirect, the lineage's certificate and key
6
+ load as a pair, the running nginx answers the name with that lineage's certificate, and no other server
7
+ block in the whole configuration nginx loads names it. Every other state, including any doubt, keeps the
8
+ install. Only fixed state tokens are printed, never file content.
9
+
10
+ A skipped install still tests and reloads nginx (`ensure_certificate.sh`), as the install did.
11
+ """
12
+ import os
13
+ from pathlib import Path
14
+ import re
15
+ import socket
16
+ import ssl
17
+ import subprocess
18
+ import sys
19
+
20
+ DOMAIN = re.compile(r"[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?")
21
+ TLS_LISTENS = {("443", "ssl"), ("[::]:443", "ssl", "ipv6only=on")}
22
+ HTTP_LISTENS = {("80",), ("[::]:80",)}
23
+ REDIRECT = [("return", ("301", "https://$host$request_uri"), None)]
24
+ WORD_END = " \t\r\n;{"
25
+ # Wherever nginx could start a token spelled `server_name`, bare or quoted, and in comments too.
26
+ SERVER_NAME = re.compile(rb"""(?<![^\s;{}#"'])server_name(?=[\s"'])""")
27
+ # A word nginx reads as one, or whose content a pair of quotes holds whole: no `#` or other quote, and no
28
+ # backslash that could escape the blank or `;` after it. A word holding a backslash is never a domain name.
29
+ READABLE_NAME = re.compile(rb"""(?:[^#"'\\]|\\[^#"'])*|(["'])[^#"'\\]*\1""")
30
+ WORD = re.compile(rb"[^ \t\r\n]+") # nginx separates words by these four only
31
+ PEM_CERTIFICATE = re.compile(r"-----BEGIN CERTIFICATE-----\n.+?\n-----END CERTIFICATE-----", re.S)
32
+ ORIGIN = ("127.0.0.1", 443)
33
+ LOADED = ("nginx", "-T")
34
+ DUMPED_FILE = re.compile(rb"^# configuration file (.*):$", re.M)
35
+
36
+
37
+ class Unparsable(ValueError):
38
+ pass
39
+
40
+
41
+ def tokens(text):
42
+ """Yield (is_word, value); refuse escapes, variables in braces and stray quotes or braces in words."""
43
+ index, size = 0, len(text)
44
+ while index < size:
45
+ char = text[index]
46
+ if char in " \t\r\n":
47
+ index += 1
48
+ elif char == "#":
49
+ end = text.find("\n", index)
50
+ index = size if end < 0 else end
51
+ elif char in ";{}":
52
+ yield False, char
53
+ index += 1
54
+ elif char in "\"'":
55
+ end = text.find(char, index + 1)
56
+ if end < 0 or "\\" in text[index + 1:end] or text[end + 1:end + 2] not in ("",) + tuple(WORD_END):
57
+ raise Unparsable
58
+ yield True, text[index + 1:end]
59
+ index = end + 1
60
+ else:
61
+ end = index
62
+ while end < size and text[end] not in WORD_END:
63
+ end += 1
64
+ word = text[index:end]
65
+ if any(mark in word for mark in ("}", "\"", "'", "\\", "${")):
66
+ raise Unparsable
67
+ yield True, word
68
+ index = end
69
+
70
+
71
+ def parse(text):
72
+ """Return top-level (name, arguments, children) entries; children is None for a simple directive."""
73
+ stack, words = [[]], []
74
+ for is_word, value in tokens(text):
75
+ if is_word:
76
+ words.append(value)
77
+ elif value == "}":
78
+ if words or len(stack) == 1:
79
+ raise Unparsable
80
+ stack.pop()
81
+ elif not words:
82
+ raise Unparsable
83
+ else:
84
+ children = [] if value == "{" else None
85
+ stack[-1].append((words[0], tuple(words[1:]), children))
86
+ words = []
87
+ if children is not None:
88
+ stack.append(children)
89
+ if words or len(stack) != 1:
90
+ raise Unparsable
91
+ return stack[0]
92
+
93
+
94
+ def values(block, name):
95
+ found = [entry for entry in block if entry[0] == name]
96
+ if any(children is not None for _, _, children in found):
97
+ raise Unparsable
98
+ return [arguments for _, arguments, _ in found]
99
+
100
+
101
+ def role(block, domain):
102
+ """Classify one server block as Certbot leaves it: `tls` on 443, `http` on 80, else `ambiguous`."""
103
+ listens = values(block, "listen")
104
+ if values(block, "server_name") != [(domain,)] or values(block, "ssl") or len(set(listens)) != len(listens):
105
+ return "ambiguous"
106
+ if listens and set(listens) <= TLS_LISTENS and ("443", "ssl") in listens:
107
+ return "tls"
108
+ if listens and set(listens) <= HTTP_LISTENS and ("80",) in listens:
109
+ return "http"
110
+ return "ambiguous"
111
+
112
+
113
+ def redirected(block, domain):
114
+ """Certbot's own redirect test: `if ($host = <domain>) { return 301 https://$host$request_uri; }`."""
115
+ condition = ("($host", "=", domain + ")")
116
+ return any(name == "if" and arguments == condition and children == REDIRECT
117
+ for name, arguments, children in block)
118
+
119
+
120
+ def loaded_configuration(command=LOADED):
121
+ """`nginx -T`: every file nginx loads from disk, each after a `# configuration file <path>:` line, and the
122
+ test's warnings. The dump holds secrets such as ingress tokens, so it stays in this process's memory."""
123
+ result = subprocess.run(command, capture_output=True, timeout=30, check=True)
124
+ return result.stdout, result.stderr
125
+
126
+
127
+ def named(text, domain):
128
+ """How many `server_name` words name the domain, or None when that count could be wrong.
129
+
130
+ Directives are only scanned, never parsed. nginx loads a `server_name` directive only where a token can start,
131
+ spelled bare or quoted, and ends it at a `;`; the scan takes every such place, in comments and strings too, so
132
+ a commented-out name counts, while a reference such as an upstream host is not a server_name. Up to the first
133
+ `;`, nginx reads exactly the words between blanks, unquoting a word a pair of quotes holds whole, unless a
134
+ `#`, another quote or a backslash before a blank or that `;` could end the statement later or change a name
135
+ (`server_name a # b;` goes on to the next line). Any of those is doubt, whatever the statement names, and so is
136
+ a quoted directive name, whose closing quote stands as a word of its own; a regular expression's own escapes
137
+ are not. nginx and Certbot take `.<domain>` for the domain itself. Only exact names count: a wildcard or a
138
+ regular expression that also matches the domain is not a duplicate, because Certbot (like nginx) ranks the
139
+ exact-name block above it for both the certificate and the port-80 redirect, so the install would edit the
140
+ managed block anyway and skipping it changes neither.
141
+ """
142
+ wanted, count = {domain.encode(), b"." + domain.encode()}, 0
143
+ for match in SERVER_NAME.finditer(text):
144
+ end = match.end()
145
+ stop = text.find(b";", end)
146
+ if stop < 0:
147
+ continue # no directive nginx loads lacks its `;`
148
+ for word in WORD.findall(text, end, stop):
149
+ if not READABLE_NAME.fullmatch(word):
150
+ return None
151
+ count += word.strip(b"\"'").lower() in wanted
152
+ return count
153
+
154
+
155
+ def named_elsewhere(path, domain, loaded):
156
+ """Doubt unless the loaded configuration names the domain only in this file, loaded once.
157
+
158
+ Certbot weighs every server block nginx loads, whichever `include` reaches it, so this reads the whole
159
+ configuration as `nginx -T` dumps it, not one directory, and a statement `named` cannot count anywhere in it,
160
+ this file included, is doubt. The whole dump is counted against this file, so a section line written inside
161
+ another file cannot hide a name; nginx dumps a file once however often it is included, and warns when a
162
+ second copy conflicts.
163
+ """
164
+ dump, warnings = loaded
165
+ here = named(path.read_bytes(), domain)
166
+ return (DUMPED_FILE.findall(dump).count(os.fsencode(path)) != 1 or here is None or here != named(dump, domain)
167
+ or b'conflicting server name "' + domain.encode() + b'"' in warnings.lower())
168
+
169
+
170
+ def configured(servers, domain, fullchain, privkey):
171
+ roles = [role(block, domain) for block in servers]
172
+ if not servers or "ambiguous" in roles or roles.count("tls") > 1 or roles.count("http") > 1:
173
+ return "ambiguous"
174
+ if "tls" not in roles:
175
+ return "http_only"
176
+ tls = servers[roles.index("tls")]
177
+ certificates, keys = values(tls, "ssl_certificate"), values(tls, "ssl_certificate_key")
178
+ if len(certificates) != 1 or len(keys) != 1:
179
+ return "ambiguous"
180
+ if certificates != [(fullchain,)] or keys != [(privkey,)]:
181
+ return "other_certificate"
182
+ if "http" not in roles or not redirected(servers[roles.index("http")], domain):
183
+ return "no_redirect"
184
+ return "current"
185
+
186
+
187
+ def leaf_certificate(fullchain):
188
+ """DER of the lineage's first certificate, which nginx presents as the leaf."""
189
+ match = PEM_CERTIFICATE.search(Path(fullchain).read_text(encoding="ascii"))
190
+ return ssl.PEM_cert_to_DER_cert(match.group(0)) if match else b""
191
+
192
+
193
+ def paired(fullchain, privkey):
194
+ """Load the lineage as nginx does: a key that does not match the leaf fails the next reload host-wide.
195
+
196
+ The empty password refuses an encrypted key at once instead of prompting for one.
197
+ """
198
+ try:
199
+ ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER).load_cert_chain(fullchain, privkey, password=b"")
200
+ except ssl.SSLError:
201
+ return False
202
+ return True
203
+
204
+
205
+ def served_certificate(domain, origin=ORIGIN):
206
+ """DER of the certificate the running nginx presents for this name; the file alone may not be loaded."""
207
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
208
+ context.check_hostname = False
209
+ context.verify_mode = ssl.CERT_NONE
210
+ with socket.create_connection(origin, timeout=5) as connection:
211
+ with context.wrap_socket(connection, server_hostname=domain) as tls:
212
+ return tls.getpeercert(binary_form=True)
213
+
214
+
215
+ def state(vhost, domain, fullchain, privkey, probe=served_certificate, dump=loaded_configuration):
216
+ path = Path(vhost)
217
+ if not DOMAIN.fullmatch(domain) or not all(value.startswith("/") for value in (vhost, fullchain, privkey)):
218
+ return "ambiguous"
219
+ if path.is_symlink() or (path.exists() and not path.is_file()):
220
+ return "ambiguous"
221
+ if not path.exists():
222
+ return "absent"
223
+ entries = parse(path.read_text(encoding="utf-8"))
224
+ if any(name not in ("map", "server") or children is None for name, _, children in entries):
225
+ return "ambiguous"
226
+ servers = [children for name, _, children in entries if name == "server"]
227
+ result = configured(servers, domain, fullchain, privkey)
228
+ if result != "current":
229
+ return result
230
+ leaf = leaf_certificate(fullchain) if os.path.isfile(fullchain) and os.path.isfile(privkey) else b""
231
+ if not leaf or not paired(fullchain, privkey):
232
+ return "lineage_unreadable"
233
+ try:
234
+ live = probe(domain)
235
+ except OSError:
236
+ return "unreachable"
237
+ if live != leaf:
238
+ return "not_served"
239
+ # Last: the handshake is cheap, while the dump loads and tests the host's whole configuration.
240
+ try:
241
+ loaded = dump()
242
+ except (OSError, subprocess.SubprocessError):
243
+ return "config_unknown"
244
+ return "duplicate" if named_elsewhere(path, domain, loaded) else "current"
245
+
246
+
247
+ def main(arguments, probe=served_certificate, dump=loaded_configuration):
248
+ if len(arguments) != 4:
249
+ return "ambiguous"
250
+ try:
251
+ return state(*arguments, probe=probe, dump=dump)
252
+ except ValueError:
253
+ return "unparsable"
254
+ except OSError:
255
+ return "unreadable"
256
+
257
+
258
+ if __name__ == "__main__":
259
+ print(main(sys.argv[1:]))
@@ -4,10 +4,15 @@ import os
4
4
  from pathlib import Path
5
5
  import re
6
6
  import stat
7
+ import subprocess
7
8
  import sys
8
9
  import tempfile
9
10
 
10
11
 
12
+ class NginxRejected(Exception):
13
+ """nginx refused the updated vhost; the previous file is back in place."""
14
+
15
+
11
16
  def transport(text, domain):
12
17
  variable = "$gowalk_backend_connection_" + hashlib.sha256(domain.encode()).hexdigest()[:16]
13
18
  mapping = f"map $http_upgrade {variable} {{\n default upgrade;\n '' close;\n}}\n\n"
@@ -59,29 +64,52 @@ def content(previous, domain, port):
59
64
  """, domain)
60
65
 
61
66
 
62
- def configure(path, domain, port):
63
- if path.is_symlink():
64
- raise ValueError("managed vhost must not be a symlink")
65
- previous = path.read_text() if path.exists() else ""
66
- updated = content(previous, domain, port)
67
- if previous == updated:
68
- return "unchanged"
69
- mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o644
67
+ def replace(path, data, mode):
70
68
  fd, temporary = tempfile.mkstemp(prefix=path.name + ".", dir=path.parent)
71
69
  try:
72
- with os.fdopen(fd, "w") as output:
70
+ with os.fdopen(fd, "wb") as output:
73
71
  os.fchmod(output.fileno(), mode)
74
- output.write(updated)
72
+ output.write(data)
75
73
  output.flush()
76
74
  os.fsync(output.fileno())
77
75
  os.replace(temporary, path)
78
76
  finally:
79
77
  Path(temporary).unlink(missing_ok=True)
78
+
79
+
80
+ def configure(path, domain, port, run=subprocess.run):
81
+ """Write the vhost, then test and reload nginx; on any failure restore the previous file, as edge does.
82
+
83
+ A change nginx refuses must not stay on disk: every later `nginx -t` on the host, this deploy's own
84
+ certificate step included, would fail on it, and the next deploy would find the file unchanged. The test
85
+ and reload run here, in one process that writes nothing to the deploy's output, so a cancelled deploy's
86
+ dropped SSH session cannot stop between them.
87
+ """
88
+ if path.is_symlink():
89
+ raise ValueError("managed vhost must not be a symlink")
90
+ previous = path.read_text() if path.exists() else ""
91
+ updated = content(previous, domain, port)
92
+ if previous == updated:
93
+ return "unchanged"
94
+ saved = path.read_bytes() if path.exists() else None
95
+ mode = stat.S_IMODE(path.stat().st_mode) if saved is not None else 0o644
96
+ replace(path, updated.encode(), mode)
97
+ try:
98
+ run(["nginx", "-t"], capture_output=True, timeout=30, check=True)
99
+ run(["systemctl", "reload", "nginx"], capture_output=True, timeout=30, check=True)
100
+ except (OSError, subprocess.SubprocessError):
101
+ if saved is None:
102
+ path.unlink(missing_ok=True)
103
+ else:
104
+ replace(path, saved, mode)
105
+ raise NginxRejected from None
80
106
  return "changed"
81
107
 
82
108
 
83
109
  if __name__ == "__main__":
84
110
  try:
85
111
  print(configure(Path(sys.argv[1]), sys.argv[2], int(sys.argv[3])))
112
+ except NginxRejected:
113
+ sys.exit("nginx rejected the managed vhost update; the previous vhost is restored")
86
114
  except (OSError, ValueError):
87
115
  sys.exit("Cannot update the managed nginx vhost; its domain and upstream must be unambiguous")
@@ -1,13 +1,11 @@
1
1
  """Protect a distinct edge vhost while retaining an app's released direct-host address."""
2
- import os
3
2
  from pathlib import Path
4
3
  import re
5
4
  import stat
6
5
  import subprocess
7
6
  import sys
8
- import tempfile
9
7
 
10
- from configure_vhost import content
8
+ from configure_vhost import content, replace
11
9
  from edge_inputs import load
12
10
 
13
11
  NGINX_DIR = Path("/etc/nginx/conf.d")
@@ -71,19 +69,6 @@ def edge_content(text, domain, port, include):
71
69
  return updated
72
70
 
73
71
 
74
- def replace(path, data, mode):
75
- fd, temporary = tempfile.mkstemp(prefix=path.name + ".", dir=path.parent)
76
- try:
77
- with os.fdopen(fd, "wb") as output:
78
- os.fchmod(output.fileno(), mode)
79
- output.write(data)
80
- output.flush()
81
- os.fsync(output.fileno())
82
- os.replace(temporary, path)
83
- finally:
84
- Path(temporary).unlink(missing_ok=True)
85
-
86
-
87
72
  def planned(config, app, domain, port, nginx_dir=NGINX_DIR):
88
73
  value = validate(config, app, domain, nginx_dir)
89
74
  if value["ingress_required"] != "true":