gowalk-cicd 1.0.164 → 1.0.167
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 +15 -2
- package/android-action/scripts/gradle_wrapper.py +2 -1
- package/android-action/scripts/test_gradle_wrapper.py +7 -0
- package/backend-action/action.yml +9 -6
- package/backend-action/certificate_vhost.py +259 -0
- package/backend-action/configure_vhost.py +38 -10
- package/backend-action/edge_vhost.py +1 -16
- package/backend-action/ensure_certificate.sh +61 -1
- package/backend-action/remote_deploy.sh +8 -3
- package/backend-action/runtime_env.py +76 -0
- package/backend-action/runtime_env_parse.py +164 -0
- package/backend-action/test_certificate_vhost.py +453 -0
- package/backend-action/test_edge_vhost.py +40 -0
- package/backend-action/test_network_docker.py +1 -1
- package/backend-action/test_runtime_env.py +229 -0
- package/bin/cli.mjs +0 -16
- package/docs/CONFIGURATION.md +0 -32
- package/package.json +2 -8
- package/action/.daemux-version +0 -1
- package/android-action/.daemux-version +0 -1
- package/backend-action/.daemux-version +0 -1
- package/scripts/lib/checks.mjs +0 -73
- package/scripts/lib/coordination.mjs +0 -39
- package/scripts/lib/discovery.mjs +0 -27
- package/scripts/lib/github.mjs +0 -85
- package/scripts/lib/repository.mjs +0 -82
- package/scripts/lib/state.mjs +0 -39
- package/scripts/upgrade-org.mjs +0 -81
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
|
|
214
|
-
|
|
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`
|
|
@@ -87,13 +87,14 @@ def _exists(name: str) -> bool | None:
|
|
|
87
87
|
return None
|
|
88
88
|
|
|
89
89
|
|
|
90
|
-
def normalize(version: Version, probe=
|
|
90
|
+
def normalize(version: Version, probe=None) -> str:
|
|
91
91
|
"""The distribution name to write into distributionUrl.
|
|
92
92
|
|
|
93
93
|
Probes services.gradle.org so a future naming change corrects itself; falls
|
|
94
94
|
back to the ordering in `candidates` when the network is unavailable, which
|
|
95
95
|
is no worse than not probing at all.
|
|
96
96
|
"""
|
|
97
|
+
probe = probe or _exists
|
|
97
98
|
options = candidates(version)
|
|
98
99
|
for name in options:
|
|
99
100
|
if probe(name) is True:
|
|
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|
|
6
6
|
import tempfile
|
|
7
7
|
import unittest
|
|
8
8
|
from pathlib import Path
|
|
9
|
+
from unittest import mock
|
|
9
10
|
|
|
10
11
|
import gradle_wrapper as gw
|
|
11
12
|
|
|
@@ -29,6 +30,12 @@ def never(_name: str) -> bool:
|
|
|
29
30
|
return False
|
|
30
31
|
|
|
31
32
|
|
|
33
|
+
def setUpModule():
|
|
34
|
+
patcher = mock.patch.object(gw, "_exists", never)
|
|
35
|
+
patcher.start()
|
|
36
|
+
unittest.addModuleCleanup(patcher.stop)
|
|
37
|
+
|
|
38
|
+
|
|
32
39
|
class RenderTest(unittest.TestCase):
|
|
33
40
|
def test_eight_x_drops_a_trailing_zero_patch(self):
|
|
34
41
|
self.assertEqual(gw.candidates((8, 14, 0))[0], "8.14")
|
|
@@ -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;
|
|
153
|
-
#
|
|
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
|
-
|
|
214
|
+
python3 "${{ github.action_path }}/runtime_env.py" export | $SSH \
|
|
212
215
|
"${{ inputs.ssh-user }}@${{ inputs.host }}" \
|
|
213
|
-
"
|
|
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
|
|
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, "
|
|
70
|
+
with os.fdopen(fd, "wb") as output:
|
|
73
71
|
os.fchmod(output.fileno(), mode)
|
|
74
|
-
output.write(
|
|
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":
|
|
@@ -54,6 +54,23 @@ certbot_attempt() {
|
|
|
54
54
|
return "$status"
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
# A skipped install still does to nginx what the install did on every deploy: test the configuration, then
|
|
58
|
+
# reload it, so a change on disk the running nginx never loaded is loaded now. The commands' own output stays
|
|
59
|
+
# private; a failure ends the deploy as a failed install did, with its status and the fixed cause Certbot
|
|
60
|
+
# reports for a failed nginx test or reload.
|
|
61
|
+
nginx_attempt() {
|
|
62
|
+
local phase="$1" attempt="$2" status
|
|
63
|
+
shift 2
|
|
64
|
+
if "$@" > "$CERTBOT_WORK/stdout" 2> "$CERTBOT_WORK/stderr"; then
|
|
65
|
+
log "certificate phase=$phase attempt=$attempt state=ok exit=0"
|
|
66
|
+
return 0
|
|
67
|
+
else
|
|
68
|
+
status=$?
|
|
69
|
+
fi
|
|
70
|
+
log "certificate phase=$phase attempt=$attempt state=error exit=$status cause=nginx_restart"
|
|
71
|
+
return "$status"
|
|
72
|
+
}
|
|
73
|
+
|
|
57
74
|
certificate_present() {
|
|
58
75
|
# 0=present, 1=confirmed absent, 2=unrecognized/partial inventory; preserve other awk failures.
|
|
59
76
|
awk -v domain="$1" -v emit_name="${2:-}" '
|
|
@@ -109,6 +126,41 @@ certificate_present() {
|
|
|
109
126
|
' "$CERTBOT_WORK/stdout"
|
|
110
127
|
}
|
|
111
128
|
|
|
129
|
+
# Print the lineage's certificate and key paths, one per line, exactly as `certbot install` deploys them.
|
|
130
|
+
certificate_paths() {
|
|
131
|
+
awk -v name="$1" '
|
|
132
|
+
$1 == "Certificate" && $2 == "Name:" {
|
|
133
|
+
current = $0; sub(/^[[:space:]]*Certificate Name:[[:space:]]*/, "", current)
|
|
134
|
+
if (current == name) records++
|
|
135
|
+
next
|
|
136
|
+
}
|
|
137
|
+
current != name { next }
|
|
138
|
+
$1 == "Certificate" && $2 == "Path:" { chains++; chain = NF == 3 ? $3 : "" }
|
|
139
|
+
$1 == "Private" && $2 == "Key" && $3 == "Path:" { keys++; key = NF == 4 ? $4 : "" }
|
|
140
|
+
END {
|
|
141
|
+
if (records != 1 || chains != 1 || keys != 1 || chain == "" || key == "") exit 1
|
|
142
|
+
print chain; print key
|
|
143
|
+
}
|
|
144
|
+
' "$CERTBOT_WORK/stdout"
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
# A repeated `install --nginx` saves a checkpoint of every nginx file on the host even when it changes
|
|
148
|
+
# nothing. Only `current` skips it: the vhost holds exactly this lineage behind Certbot's redirect, the
|
|
149
|
+
# running nginx presents that certificate for the name, and no other loaded server block names it.
|
|
150
|
+
certificate_vhost_state() {
|
|
151
|
+
local domain="$1" name="$2" vhost="$3" paths state
|
|
152
|
+
[ -n "$vhost" ] || { printf 'no_vhost'; return 0; }
|
|
153
|
+
paths="$(certificate_paths "$name")" || { printf 'lineage_unknown'; return 0; }
|
|
154
|
+
state="$(python3 "$(dirname "${BASH_SOURCE[0]}")/certificate_vhost.py" "$vhost" "$domain" \
|
|
155
|
+
"${paths%%$'\n'*}" "${paths#*$'\n'}" 2>/dev/null)" || state=check_failed
|
|
156
|
+
case "$state" in
|
|
157
|
+
current|absent|http_only|other_certificate|no_redirect|lineage_unreadable|duplicate) ;;
|
|
158
|
+
config_unknown|not_served|unreachable|ambiguous|unparsable|unreadable) ;;
|
|
159
|
+
*) state=check_failed ;;
|
|
160
|
+
esac
|
|
161
|
+
printf '%s' "$state"
|
|
162
|
+
}
|
|
163
|
+
|
|
112
164
|
request_certificate() {
|
|
113
165
|
local domain="$1" email="$2" attempt="$3" account
|
|
114
166
|
# Certbot tells the CA to validate one second after reloading nginx by default. A host with
|
|
@@ -125,7 +177,7 @@ request_certificate() {
|
|
|
125
177
|
}
|
|
126
178
|
|
|
127
179
|
ensure_certificate() (
|
|
128
|
-
local domain="$1" email="$2" delay name attempt=0 status=1 CERTBOT_WORK
|
|
180
|
+
local domain="$1" email="$2" vhost="${3:-}" delay name vhost_state attempt=0 status=1 CERTBOT_WORK
|
|
129
181
|
CERTBOT_WORK="$(mktemp -d "${TMPDIR:-/tmp}/gowalk-certbot.XXXXXXXX")" || return $?
|
|
130
182
|
trap 'rm -rf -- "$CERTBOT_WORK"' EXIT
|
|
131
183
|
trap 'exit 130' INT
|
|
@@ -140,6 +192,14 @@ ensure_certificate() (
|
|
|
140
192
|
if certbot_attempt inventory "$attempt" certificates; then
|
|
141
193
|
if name="$(certificate_present "$domain" name)"; then
|
|
142
194
|
log "cert for $domain already present"
|
|
195
|
+
vhost_state="$(certificate_vhost_state "$domain" "$name" "$vhost")"
|
|
196
|
+
if [ "$vhost_state" = current ]; then
|
|
197
|
+
log "certificate phase=installation attempt=$attempt state=skipped vhost=current"
|
|
198
|
+
nginx_attempt nginx_test "$attempt" nginx -t || return $?
|
|
199
|
+
nginx_attempt nginx_reload "$attempt" systemctl reload nginx || return $?
|
|
200
|
+
return 0
|
|
201
|
+
fi
|
|
202
|
+
log "certificate phase=installation attempt=$attempt state=required vhost=$vhost_state"
|
|
143
203
|
# Inventory proves possession, not nginx installation. Repair an HTTP-only legacy vhost too.
|
|
144
204
|
if certbot_attempt installation "$attempt" install --nginx --cert-name "$name" \
|
|
145
205
|
-d "$domain" --non-interactive --redirect; then
|
|
@@ -26,8 +26,9 @@ log "host $(python3 --version)"
|
|
|
26
26
|
|
|
27
27
|
# Refuse a missing ingress token or an older unprotected workflow before Compose changes.
|
|
28
28
|
EDGE_CONFIG="/opt/gowalk-backend-ingress/$APP/.backend-edge.json"
|
|
29
|
+
EDGE_VHOST="/etc/nginx/conf.d/backend-$APP-edge.conf"
|
|
29
30
|
EDGE_MODE=legacy
|
|
30
|
-
if [ -f "$EDGE_CONFIG" ] || [ -e "
|
|
31
|
+
if [ -f "$EDGE_CONFIG" ] || [ -e "$EDGE_VHOST" ]; then
|
|
31
32
|
python3 "$DIR/edge_vhost.py" validate "$EDGE_CONFIG" "$APP" "$DOMAIN"
|
|
32
33
|
EDGE_MODE="$(python3 "$DIR/edge_inputs.py" mode "$EDGE_CONFIG")"
|
|
33
34
|
python3 "$DIR/edge_inputs.py" runtime "$EDGE_CONFIG"
|
|
@@ -101,19 +102,23 @@ fi
|
|
|
101
102
|
# ── nginx vhost → proxy the domain to the published port
|
|
102
103
|
BACKEND_DEPLOY_PHASE=configure_vhost
|
|
103
104
|
VHOST="/etc/nginx/conf.d/backend-$APP.conf"
|
|
105
|
+
CERT_VHOST="$VHOST"
|
|
104
106
|
if [ "${EDGE_MODE:-legacy}" = edge ]; then
|
|
107
|
+
CERT_VHOST="$EDGE_VHOST"
|
|
105
108
|
python3 "$DIR/edge_vhost.py" configure "$EDGE_CONFIG" "$APP" "$DOMAIN" "$PORT"
|
|
106
109
|
else
|
|
110
|
+
# Tests and reloads nginx itself after a change, restoring the previous file on failure, so a change nginx
|
|
111
|
+
# refuses never stays on disk to fail the host's next test.
|
|
107
112
|
VHOST_STATE="$(python3 "$DIR/configure_vhost.py" "$VHOST" "$DOMAIN" "$PORT")"
|
|
108
113
|
if [ "$VHOST_STATE" = changed ]; then
|
|
109
114
|
log "updated nginx upstream for $DOMAIN → 127.0.0.1:$PORT"
|
|
110
|
-
nginx -t && systemctl reload nginx
|
|
111
115
|
fi
|
|
112
116
|
fi
|
|
113
117
|
|
|
114
118
|
# ── Let's Encrypt cert: failed inventory is never absence; only lock contention retries.
|
|
119
|
+
# The domain's own vhost tells whether `certbot install` would change anything.
|
|
115
120
|
BACKEND_DEPLOY_PHASE=certificate
|
|
116
|
-
if ensure_certificate "$DOMAIN" "$CERT_EMAIL"; then
|
|
121
|
+
if ensure_certificate "$DOMAIN" "$CERT_EMAIL" "$CERT_VHOST"; then
|
|
117
122
|
log "certificate ready for $DOMAIN"
|
|
118
123
|
else
|
|
119
124
|
status=$?
|