gowalk-cicd 1.0.191 → 1.0.193
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 +11 -0
- package/backend-action/action.yml +48 -3
- package/backend-action/install_runtime.py +5 -3
- package/backend-action/network_diagnostics.py +65 -0
- package/backend-action/network_host.py +149 -0
- package/backend-action/network_inventory.py +152 -0
- package/backend-action/network_metadata.py +91 -0
- package/backend-action/network_projection.py +151 -0
- package/backend-action/network_readback.py +65 -0
- package/backend-action/runtime_projection.py +1 -1
- package/backend-action/ssh_gate.py +13 -0
- package/docs/BACKEND_SSH_GATE.md +57 -2
- package/package.json +1 -1
- package/templates/deploy-backend.yml +35 -3
- package/templates/history.json.gz +0 -0
package/README.md
CHANGED
|
@@ -1372,3 +1372,14 @@ provenance. Call it from the app's read-only diagnostic workflow with `diagnosti
|
|
|
1372
1372
|
`expected-build-sha`, using its existing host/key/pin inputs. It requires the released host forced command as well
|
|
1373
1373
|
as the updated action. See [the scoped runtime contract and host installer](docs/BACKEND_SSH_GATE.md#applied-runtime-readback).
|
|
1374
1374
|
Identity failure logs and source Compose files do not establish this applied state.
|
|
1375
|
+
|
|
1376
|
+
### Read-only bridge and VPN prerequisite diagnostics
|
|
1377
|
+
|
|
1378
|
+
Backend Deploy also offers `operation: network-diagnostics`, with an exact `diagnostic-service`, deployed
|
|
1379
|
+
`diagnostic-build-sha`, owned `diagnostic-network`, proposed namespace-only private IPv4 `/24` in
|
|
1380
|
+
`diagnostic-pool`, exact `diagnostic-vpn-service`, and optional `diagnostic-provider: digitalocean`.
|
|
1381
|
+
It reports the owned bridge/IPAM, observed pool conflicts, UDP 500/4500 ownership, bound host identity and
|
|
1382
|
+
kernel/firewall facts in a closed v3 artifact. The allocator stays retired; a completed read is never
|
|
1383
|
+
deployment, port reservation, globally unused address space or tunnel proof. Effective firewall ingress and
|
|
1384
|
+
unobserved capabilities remain unverified. The [trusted-host procedure](docs/BACKEND_SSH_GATE.md#owned-bridge-and-vpn-prerequisite-readback)
|
|
1385
|
+
defines scope, retained evidence and the required host runtime adoption before this operation is available.
|
|
@@ -7,7 +7,7 @@ description: >
|
|
|
7
7
|
|
|
8
8
|
inputs:
|
|
9
9
|
operation:
|
|
10
|
-
description: deploy (default), identity-diagnostics or
|
|
10
|
+
description: deploy (default), identity-diagnostics, runtime-diagnostics or read-only network-diagnostics.
|
|
11
11
|
required: false
|
|
12
12
|
default: deploy
|
|
13
13
|
diagnostic-service:
|
|
@@ -19,9 +19,21 @@ inputs:
|
|
|
19
19
|
required: false
|
|
20
20
|
default: ""
|
|
21
21
|
diagnostic-provider:
|
|
22
|
-
description:
|
|
22
|
+
description: Fixed metadata corroboration for network-diagnostics (none or digitalocean); allocates nothing.
|
|
23
23
|
required: false
|
|
24
24
|
default: none
|
|
25
|
+
diagnostic-network:
|
|
26
|
+
description: Exact owned Compose bridge network key for network-diagnostics.
|
|
27
|
+
required: false
|
|
28
|
+
default: default
|
|
29
|
+
diagnostic-pool:
|
|
30
|
+
description: Proposed strict private IPv4 /24 for a VPN container namespace; never a host allocation.
|
|
31
|
+
required: false
|
|
32
|
+
default: ""
|
|
33
|
+
diagnostic-vpn-service:
|
|
34
|
+
description: Exact owned Compose service whose existing UDP 500/4500 bindings may be reused.
|
|
35
|
+
required: false
|
|
36
|
+
default: vpn
|
|
25
37
|
host:
|
|
26
38
|
description: Explicit SSH host from the backend binding; no infrastructure fallback.
|
|
27
39
|
required: true
|
|
@@ -106,7 +118,7 @@ runs:
|
|
|
106
118
|
API_DOMAIN: ${{ inputs.api-domain }}
|
|
107
119
|
DEPLOY_HOST: ${{ inputs.host }}
|
|
108
120
|
run: |
|
|
109
|
-
case "$OPERATION" in deploy|identity-diagnostics|runtime-diagnostics) ;; *) exit 1;; esac
|
|
121
|
+
case "$OPERATION" in deploy|identity-diagnostics|runtime-diagnostics|network-diagnostics) ;; *) exit 1;; esac
|
|
110
122
|
# Only a repository with a bound origin holds the deploy key; the panel sets the domain when it binds one.
|
|
111
123
|
if [ "$OPERATION" = deploy ] && [ -z "$API_DOMAIN" ]; then
|
|
112
124
|
echo "::notice title=backend_origin_unbound::No api-domain: the backend origin is not bound yet, nothing deployed"
|
|
@@ -306,6 +318,39 @@ runs:
|
|
|
306
318
|
path: ${{ steps.runtime_diagnostics.outputs.result }}
|
|
307
319
|
if-no-files-found: error
|
|
308
320
|
|
|
321
|
+
- name: Read scoped backend network prerequisites
|
|
322
|
+
id: network_diagnostics
|
|
323
|
+
if: inputs.operation == 'network-diagnostics'
|
|
324
|
+
shell: bash
|
|
325
|
+
env:
|
|
326
|
+
DIAGNOSTIC_APP: ${{ inputs.app-name }}
|
|
327
|
+
DIAGNOSTIC_SERVICE: ${{ inputs.diagnostic-service }}
|
|
328
|
+
DIAGNOSTIC_HOST: ${{ inputs.host }}
|
|
329
|
+
DIAGNOSTIC_USER: ${{ inputs.ssh-user }}
|
|
330
|
+
DIAGNOSTIC_BUILD: ${{ inputs.expected-build-sha }}
|
|
331
|
+
DIAGNOSTIC_NETWORK: ${{ inputs.diagnostic-network }}
|
|
332
|
+
DIAGNOSTIC_POOL: ${{ inputs.diagnostic-pool }}
|
|
333
|
+
DIAGNOSTIC_VPN_SERVICE: ${{ inputs.diagnostic-vpn-service }}
|
|
334
|
+
DIAGNOSTIC_PROVIDER: ${{ inputs.diagnostic-provider }}
|
|
335
|
+
run: |
|
|
336
|
+
RESULT_DIR="$(mktemp -d "$RUNNER_TEMP/backend-network-diagnostics.XXXXXX")"
|
|
337
|
+
echo "result=$RESULT_DIR/result.json" >> "$GITHUB_OUTPUT"
|
|
338
|
+
python3 "${{ github.action_path }}/network_diagnostics.py" \
|
|
339
|
+
--app "$DIAGNOSTIC_APP" --service "$DIAGNOSTIC_SERVICE" --expected-build-sha "$DIAGNOSTIC_BUILD" \
|
|
340
|
+
--network "$DIAGNOSTIC_NETWORK" --pool "$DIAGNOSTIC_POOL" --vpn-service "$DIAGNOSTIC_VPN_SERVICE" \
|
|
341
|
+
--host "$DIAGNOSTIC_HOST" --user "$DIAGNOSTIC_USER" --provider "$DIAGNOSTIC_PROVIDER" \
|
|
342
|
+
--output "$RESULT_DIR/result.json"
|
|
343
|
+
|
|
344
|
+
- name: Retain scoped backend network result
|
|
345
|
+
if: always() && inputs.operation == 'network-diagnostics'
|
|
346
|
+
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
|
347
|
+
env:
|
|
348
|
+
NODE_OPTIONS: --require "${{ github.action_path }}/scripts/artifact_env.cjs"
|
|
349
|
+
with:
|
|
350
|
+
name: backend-network-diagnostics-${{ github.job }}-${{ github.run_attempt }}
|
|
351
|
+
path: ${{ steps.network_diagnostics.outputs.result }}
|
|
352
|
+
if-no-files-found: error
|
|
353
|
+
|
|
309
354
|
- name: Cleanup key
|
|
310
355
|
if: always()
|
|
311
356
|
shell: bash
|
|
@@ -21,6 +21,7 @@ REQUIRED = (
|
|
|
21
21
|
'runtime_env.py', 'runtime_env_parse.py', 'edge_inputs.py', 'edge_vhost.py', 'edge_health.py',
|
|
22
22
|
'configure_vhost.py', 'vhost_diagnostics.py', 'remote_deploy.sh', 'deploy_evidence.sh',
|
|
23
23
|
'ensure_certificate.sh', 'select_certbot_account.sh',
|
|
24
|
+
'network_projection.py', 'network_inventory.py', 'network_host.py', 'network_metadata.py', 'network_readback.py',
|
|
24
25
|
)
|
|
25
26
|
|
|
26
27
|
|
|
@@ -96,13 +97,14 @@ def update_commands(content, target, owners):
|
|
|
96
97
|
for owner in owners:
|
|
97
98
|
if not re.fullmatch(r'[a-z0-9-]{1,40}', owner):
|
|
98
99
|
raise ValueError('release_owner_invalid')
|
|
99
|
-
pattern = re.compile(r'command="
|
|
100
|
+
pattern = re.compile(r'command="(?P<python>/usr/bin/python3(?: -B)?) /opt/gowalk-cicd-runtime/\d+\.\d+\.\d+'
|
|
100
101
|
r'/backend-action/ssh_gate\.py ' + re.escape(owner) + r'"')
|
|
101
102
|
matches = [index for index, line in enumerate(lines) if pattern.search(line)]
|
|
102
103
|
if len(matches) != 1 or 'restrict' not in lines[matches[0]].split(' ')[0].split(','):
|
|
103
104
|
raise ValueError('release_owner_command_ambiguous')
|
|
104
|
-
|
|
105
|
-
lines[matches[0]] = pattern.sub(lambda
|
|
105
|
+
destination = str(target / 'backend-action/ssh_gate.py') + ' ' + owner + '"'
|
|
106
|
+
lines[matches[0]] = pattern.sub(lambda match: 'command="' + match['python'] + ' ' + destination,
|
|
107
|
+
lines[matches[0]])
|
|
106
108
|
return ''.join(lines)
|
|
107
109
|
|
|
108
110
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Call only the released scoped network reader through the pinned CI backend identity."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from identity_diagnostics import ReaderFailure, bounded, retain_result
|
|
9
|
+
from network_projection import NetworkFailure, envelope, scope, validate
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def local(args):
|
|
13
|
+
fields = (args.app, args.service, args.expected_build_sha, args.network, args.pool,
|
|
14
|
+
args.vpn_service, args.host, args.provider)
|
|
15
|
+
scope(*fields)
|
|
16
|
+
if not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', args.user):
|
|
17
|
+
raise NetworkFailure('invalid_scope')
|
|
18
|
+
command = shlex.join(['gowalk-backend', 'network-diagnostics', *fields])
|
|
19
|
+
try:
|
|
20
|
+
status, raw = bounded(['ssh', '-T', '-i', str(Path.home() / '.ssh/backend_deploy'),
|
|
21
|
+
'-o', 'IdentitiesOnly=yes', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes',
|
|
22
|
+
'-o', 'ConnectTimeout=10', '-o', 'ServerAliveInterval=5', '-o', 'ServerAliveCountMax=2',
|
|
23
|
+
'-o', 'ClearAllForwardings=yes', args.user + '@' + args.host, command], seconds=120, with_status=True)
|
|
24
|
+
except ReaderFailure as error:
|
|
25
|
+
raise NetworkFailure(error.code) from None
|
|
26
|
+
except OSError:
|
|
27
|
+
raise NetworkFailure('transport_failure') from None
|
|
28
|
+
if status not in (0, 1):
|
|
29
|
+
raise NetworkFailure('transport_failure')
|
|
30
|
+
if status == 1 and not raw.strip():
|
|
31
|
+
raise NetworkFailure('network_reader_unavailable')
|
|
32
|
+
try:
|
|
33
|
+
result = validate(json.loads(raw), *fields)
|
|
34
|
+
except (ValueError, TypeError, KeyError):
|
|
35
|
+
raise NetworkFailure('remote_invalid') from None
|
|
36
|
+
if result['ok'] != (status == 0):
|
|
37
|
+
raise NetworkFailure('remote_invalid')
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main():
|
|
42
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
43
|
+
for name in ('app', 'expected-build-sha', 'pool', 'host', 'output'):
|
|
44
|
+
parser.add_argument('--' + name, required=True)
|
|
45
|
+
for name, default in [('service', 'api'), ('network', 'default'), ('vpn-service', 'vpn'),
|
|
46
|
+
('provider', 'none'), ('user', 'root')]:
|
|
47
|
+
parser.add_argument('--' + name, default=default)
|
|
48
|
+
args = parser.parse_args()
|
|
49
|
+
fields = (args.app, args.service, args.expected_build_sha, args.network, args.pool,
|
|
50
|
+
args.vpn_service, args.host, args.provider)
|
|
51
|
+
try:
|
|
52
|
+
result = local(args)
|
|
53
|
+
except Exception as error:
|
|
54
|
+
result = envelope(*fields, failure=error.code if isinstance(error, NetworkFailure) else 'unclassified',
|
|
55
|
+
reader_version=None)
|
|
56
|
+
try:
|
|
57
|
+
retain_result(args.output, result)
|
|
58
|
+
except ReaderFailure:
|
|
59
|
+
result = envelope(*fields, failure='artifact_write_failed', reader_version=None)
|
|
60
|
+
print(json.dumps(result, separators=(',', ':')))
|
|
61
|
+
return 0 if result['ok'] else 1
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == '__main__':
|
|
65
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Fixed host-side observations; socket owners and firewall rules stay private."""
|
|
2
|
+
import ipaddress
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
|
|
7
|
+
from network_inventory import run
|
|
8
|
+
from network_projection import NetworkFailure
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def host_binding(host, rows):
|
|
12
|
+
try:
|
|
13
|
+
addresses = {str(ipaddress.IPv4Address(host))}
|
|
14
|
+
except ValueError:
|
|
15
|
+
raw = run(['getent', 'ahostsv4', host])
|
|
16
|
+
try:
|
|
17
|
+
addresses = {str(ipaddress.IPv4Address(line.split()[0])) for line in raw.decode().splitlines()}
|
|
18
|
+
except (ValueError, IndexError, UnicodeError):
|
|
19
|
+
raise NetworkFailure('host_binding_mismatch') from None
|
|
20
|
+
local = {row['local'] for row in rows if row['kind'] == 'address'}
|
|
21
|
+
if not addresses or not addresses.issubset(local):
|
|
22
|
+
raise NetworkFailure('host_binding_mismatch')
|
|
23
|
+
return addresses
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def listeners():
|
|
27
|
+
raw = run(['ss', '-H', '-l', '-u', '-n', '-p'])
|
|
28
|
+
values = {'500': [], '4500': []}
|
|
29
|
+
try:
|
|
30
|
+
for line in raw.decode().splitlines():
|
|
31
|
+
parts = line.split()
|
|
32
|
+
if len(parts) < 5 or parts[0] != 'UNCONN':
|
|
33
|
+
raise ValueError()
|
|
34
|
+
port = parts[3].rsplit(':', 1)[1]
|
|
35
|
+
if port in values:
|
|
36
|
+
owner = 'docker_proxy' if len(parts) > 5 and re.fullmatch(
|
|
37
|
+
r'users:\(\("docker-proxy",pid=\d+,fd=\d+\)\)', ''.join(parts[5:])) else 'unknown'
|
|
38
|
+
values[port].append(owner)
|
|
39
|
+
except (ValueError, IndexError, UnicodeError):
|
|
40
|
+
raise NetworkFailure('network_invalid') from None
|
|
41
|
+
return values
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def udp_ports(app, service, bindings, observed):
|
|
45
|
+
result = {}
|
|
46
|
+
for port in ('500', '4500'):
|
|
47
|
+
owners = []
|
|
48
|
+
for row in bindings:
|
|
49
|
+
try:
|
|
50
|
+
matches = [binding for key, entries in (row['ports'] or {}).items() if key.endswith('/udp')
|
|
51
|
+
for binding in entries or [] if str(binding['HostPort']) == port]
|
|
52
|
+
except (KeyError, TypeError, AttributeError):
|
|
53
|
+
raise NetworkFailure('network_invalid') from None
|
|
54
|
+
if matches:
|
|
55
|
+
own = (row['project'] == app and row['service'] == service
|
|
56
|
+
and row['directory'] == '/opt/gowalk-backends/' + app)
|
|
57
|
+
owners.append('app_vpn' if own else 'foreign')
|
|
58
|
+
sockets = observed[port]
|
|
59
|
+
status = 'foreign' if 'foreign' in owners else 'unknown'
|
|
60
|
+
if owners and set(owners) == {'app_vpn'} and set(sockets).issubset({'docker_proxy'}):
|
|
61
|
+
status = 'app_vpn'
|
|
62
|
+
elif not owners and not sockets:
|
|
63
|
+
status = 'free'
|
|
64
|
+
result[port] = {'status': status, 'host_listener_observed': bool(sockets),
|
|
65
|
+
'docker_binding_observed': bool(owners)}
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def firewall(bridge):
|
|
70
|
+
raw = run(['iptables', '-w', '2', '-S'], optional=True)
|
|
71
|
+
nat = run(['iptables', '-w', '2', '-t', 'nat', '-S'], optional=True)
|
|
72
|
+
result = {'reader': 'unverified', 'input_policy': 'unverified', 'forward_policy': 'unverified',
|
|
73
|
+
'udp_allow_rules': {'500': False, '4500': False}, 'effective_ingress': 'unverified'}
|
|
74
|
+
masquerade = None
|
|
75
|
+
try:
|
|
76
|
+
if raw is not None:
|
|
77
|
+
rules = [shlex.split(line) for line in raw.decode().splitlines()]
|
|
78
|
+
result['reader'] = 'observed'
|
|
79
|
+
for chain, key in [('INPUT', 'input_policy'), ('FORWARD', 'forward_policy')]:
|
|
80
|
+
matches = [words[2] for words in rules if len(words) == 3 and words[:2] == ['-P', chain]]
|
|
81
|
+
if len(matches) == 1 and matches[0] in ('ACCEPT', 'DROP'):
|
|
82
|
+
result[key] = matches[0]
|
|
83
|
+
for port in ('500', '4500'):
|
|
84
|
+
result['udp_allow_rules'][port] = any(words[:2] == ['-A', 'INPUT']
|
|
85
|
+
and '-p' in words and words[words.index('-p') + 1] == 'udp'
|
|
86
|
+
and '--dport' in words and words[words.index('--dport') + 1] == port
|
|
87
|
+
and '-j' in words and words[words.index('-j') + 1] == 'ACCEPT' for words in rules)
|
|
88
|
+
if nat is not None:
|
|
89
|
+
subnets = {str(ipaddress.ip_network(item['subnet'])) for item in bridge['ipam']}
|
|
90
|
+
rules = [shlex.split(line) for line in nat.decode().splitlines()]
|
|
91
|
+
masquerade = any(words[:2] == ['-A', 'POSTROUTING'] and '-s' in words
|
|
92
|
+
and words[words.index('-s') + 1] in subnets and '-j' in words
|
|
93
|
+
and words[words.index('-j') + 1] == 'MASQUERADE' for words in rules)
|
|
94
|
+
except (ValueError, IndexError, UnicodeError):
|
|
95
|
+
raise NetworkFailure('network_invalid') from None
|
|
96
|
+
return result, masquerade, 'available' if nat is not None else 'unverified'
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def local_docker():
|
|
100
|
+
try:
|
|
101
|
+
endpoint = json.loads(run(['docker', 'context', 'inspect', '--format', '{{json .Endpoints.docker.Host}}']))
|
|
102
|
+
except (ValueError, TypeError):
|
|
103
|
+
raise NetworkFailure('network_scope_mismatch') from None
|
|
104
|
+
if endpoint not in ('unix:///var/run/docker.sock', 'unix:///run/docker.sock'):
|
|
105
|
+
raise NetworkFailure('network_scope_mismatch')
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def namespace(containers, app, vpn_service, bridge):
|
|
109
|
+
info = run(['docker', 'info', '--format', '{"os":{{json .OSType}},"security":{{json .SecurityOptions}}}'])
|
|
110
|
+
try:
|
|
111
|
+
value = json.loads(info)
|
|
112
|
+
if set(value) != {'os', 'security'} or not isinstance(value['security'], list):
|
|
113
|
+
raise ValueError()
|
|
114
|
+
rootful = value['os'] == 'linux' and not any('rootless' in str(item) for item in value['security'])
|
|
115
|
+
except (ValueError, TypeError):
|
|
116
|
+
raise NetworkFailure('network_invalid') from None
|
|
117
|
+
forwarding = run(['cat', '/proc/sys/net/ipv4/ip_forward'], optional=True)
|
|
118
|
+
forward = None if forwarding is None else forwarding.strip() == b'1'
|
|
119
|
+
xfrm = run(['ip', 'xfrm', 'state', 'count'], optional=True)
|
|
120
|
+
return {'docker_linux_rootful': rootful, 'host_ipv4_forwarding': forward,
|
|
121
|
+
'xfrm_read': 'available' if xfrm is not None else 'unverified',
|
|
122
|
+
'vpn_settings': vpn_settings(containers, app, vpn_service, bridge)}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def vpn_settings(containers, app, service, bridge):
|
|
126
|
+
identifiers = [row['id'] for row in containers if row['service'] == service]
|
|
127
|
+
if not identifiers:
|
|
128
|
+
return 'absent'
|
|
129
|
+
if len(identifiers) != 1:
|
|
130
|
+
raise NetworkFailure('network_ambiguous')
|
|
131
|
+
template = ('{"id":{{json .Id}},"project":{{json (index .Config.Labels "com.docker.compose.project")}},'
|
|
132
|
+
'"service":{{json (index .Config.Labels "com.docker.compose.service")}},'
|
|
133
|
+
'"privileged":{{json .HostConfig.Privileged}},"network_mode":{{json .HostConfig.NetworkMode}},'
|
|
134
|
+
'"cap_add":{{json .HostConfig.CapAdd}},"cap_drop":{{json .HostConfig.CapDrop}},'
|
|
135
|
+
'"sysctls":{{json .HostConfig.Sysctls}}}')
|
|
136
|
+
try:
|
|
137
|
+
row = json.loads(run(['docker', 'inspect', '--format', template, identifiers[0]]))
|
|
138
|
+
if row['id'] != identifiers[0] or row['project'] != app or row['service'] != service:
|
|
139
|
+
raise NetworkFailure('network_scope_mismatch')
|
|
140
|
+
added = set(row['cap_add'] or [])
|
|
141
|
+
allowed = {'NET_ADMIN', 'NET_BIND_SERVICE', 'CHOWN', 'SETUID', 'SETGID'}
|
|
142
|
+
safe = (row['privileged'] is False and row['network_mode'] == bridge['name']
|
|
143
|
+
and 'NET_ADMIN' in added and added.issubset(allowed)
|
|
144
|
+
and 'ALL' in (row['cap_drop'] or [])
|
|
145
|
+
and (row['sysctls'] or {}).get('net.ipv4.ip_forward') == '1'
|
|
146
|
+
and (row['sysctls'] or {}).get('net.ipv4.conf.all.send_redirects') == '0')
|
|
147
|
+
except (ValueError, KeyError, TypeError):
|
|
148
|
+
raise NetworkFailure('network_invalid') from None
|
|
149
|
+
return 'isolated_net_admin' if safe else 'other'
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Bounded private network snapshots; foreign names and configuration never enter receipts."""
|
|
2
|
+
import ipaddress
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from identity_diagnostics import ReaderFailure, bounded
|
|
7
|
+
from network_projection import CID, NetworkFailure
|
|
8
|
+
|
|
9
|
+
NETWORK_FORMAT = ('{"id":{{json .Id}},"name":{{json .Name}},"driver":{{json .Driver}},'
|
|
10
|
+
'"project":{{json (index .Labels "com.docker.compose.project")}},'
|
|
11
|
+
'"key":{{json (index .Labels "com.docker.compose.network")}},'
|
|
12
|
+
'"bridge":{{json (index .Options "com.docker.network.bridge.name")}},"ipam":{{json .IPAM.Config}}}')
|
|
13
|
+
PORT_FORMAT = ('{"id":{{json .ID}},"project":{{json (.Label "com.docker.compose.project")}},'
|
|
14
|
+
'"service":{{json (.Label "com.docker.compose.service")}},'
|
|
15
|
+
'"directory":{{json (.Label "com.docker.compose.project.working_dir")}},"ports":{{json .Ports}}}')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run(arguments, *, optional=False):
|
|
19
|
+
try:
|
|
20
|
+
return bounded(arguments, seconds=10)
|
|
21
|
+
except ReaderFailure as error:
|
|
22
|
+
if optional and error.code == 'command_failed':
|
|
23
|
+
return None
|
|
24
|
+
raise NetworkFailure(error.code) from None
|
|
25
|
+
except OSError:
|
|
26
|
+
if optional:
|
|
27
|
+
return None
|
|
28
|
+
raise NetworkFailure('command_failed') from None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def decode(raw, *, lines=False):
|
|
32
|
+
try:
|
|
33
|
+
value = [json.loads(line) for line in raw.splitlines()] if lines else json.loads(raw)
|
|
34
|
+
if not isinstance(value, list) or len(value) > 1024:
|
|
35
|
+
raise ValueError()
|
|
36
|
+
return value
|
|
37
|
+
except (ValueError, TypeError, UnicodeError):
|
|
38
|
+
raise NetworkFailure('network_invalid') from None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def identifiers(raw):
|
|
42
|
+
try:
|
|
43
|
+
values = raw.decode('ascii').splitlines()
|
|
44
|
+
except UnicodeError:
|
|
45
|
+
raise NetworkFailure('network_invalid') from None
|
|
46
|
+
if len(values) > 1024 or len(set(values)) != len(values) or any(not re.fullmatch(CID, x) for x in values):
|
|
47
|
+
raise NetworkFailure('network_invalid')
|
|
48
|
+
return sorted(values)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def networks():
|
|
52
|
+
ids = identifiers(run(['docker', 'network', 'ls', '--no-trunc', '--format', '{{.ID}}']))
|
|
53
|
+
rows = []
|
|
54
|
+
for offset in range(0, len(ids), 32):
|
|
55
|
+
rows.extend(decode(run(['docker', 'network', 'inspect', '--format', NETWORK_FORMAT,
|
|
56
|
+
*ids[offset:offset + 32]]), lines=True))
|
|
57
|
+
if sorted(row.get('id', '') for row in rows) != ids:
|
|
58
|
+
raise NetworkFailure('network_changed')
|
|
59
|
+
for row in rows:
|
|
60
|
+
if set(row) != {'id', 'name', 'driver', 'project', 'key', 'bridge', 'ipam'}:
|
|
61
|
+
raise NetworkFailure('network_invalid')
|
|
62
|
+
for item in row['ipam'] or []:
|
|
63
|
+
try:
|
|
64
|
+
ipaddress.ip_network(item['Subnet'], strict=True)
|
|
65
|
+
except (ValueError, KeyError, TypeError):
|
|
66
|
+
raise NetworkFailure('network_invalid') from None
|
|
67
|
+
return sorted(rows, key=lambda row: row['id'])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def selected(rows, app, key):
|
|
71
|
+
matches = [row for row in rows if row['project'] == app and row['key'] == key]
|
|
72
|
+
if len(matches) != 1:
|
|
73
|
+
raise NetworkFailure('network_absent' if not matches else 'network_ambiguous')
|
|
74
|
+
row = matches[0]
|
|
75
|
+
if row['driver'] != 'bridge' or not row['ipam']:
|
|
76
|
+
raise NetworkFailure('network_scope_mismatch')
|
|
77
|
+
return {'network_id': row['id'], 'name': row['name'], 'driver': row['driver'], 'project': app, 'key': key,
|
|
78
|
+
'bridge': row['bridge'] or 'br-' + row['id'][:12],
|
|
79
|
+
'ipam': [{'subnet': x['Subnet'], 'gateway': x.get('Gateway', '')} for x in row['ipam']]}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def host_networks():
|
|
83
|
+
addresses = decode(run(['ip', '-j', 'address', 'show']))
|
|
84
|
+
routes = decode(run(['ip', '-j', '-4', 'route', 'show', 'table', 'all']))
|
|
85
|
+
result = []
|
|
86
|
+
try:
|
|
87
|
+
for row in addresses:
|
|
88
|
+
for address in row.get('addr_info', []):
|
|
89
|
+
local = ipaddress.ip_address(address['local'])
|
|
90
|
+
result.append({'kind': 'address', 'device': row['ifname'], 'local': str(local),
|
|
91
|
+
'subnet': str(ipaddress.ip_network(str(local) + '/' + str(address['prefixlen']), strict=False))})
|
|
92
|
+
for row in routes:
|
|
93
|
+
destination = row.get('dst', 'default')
|
|
94
|
+
if destination != 'default':
|
|
95
|
+
result.append({'kind': 'route', 'device': row.get('dev', ''), 'local': '',
|
|
96
|
+
'subnet': str(ipaddress.ip_network(destination, strict=False))})
|
|
97
|
+
except (ValueError, TypeError, KeyError):
|
|
98
|
+
raise NetworkFailure('network_invalid') from None
|
|
99
|
+
if len(result) > 16384:
|
|
100
|
+
raise NetworkFailure('output_limit')
|
|
101
|
+
return sorted(result, key=lambda row: (row['kind'], row['device'], row['subnet'], row['local']))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def overlap(rows, bridge, host_rows, pool):
|
|
105
|
+
proposed = ipaddress.IPv4Network(pool)
|
|
106
|
+
own = [ipaddress.ip_network(item['subnet']) for item in bridge['ipam']]
|
|
107
|
+
foreign = [ipaddress.ip_network(item['Subnet']) for row in rows if row['id'] != bridge['network_id']
|
|
108
|
+
for item in row['ipam'] or []]
|
|
109
|
+
others = [item for item in host_rows if item['device'] != bridge['bridge']]
|
|
110
|
+
intersects = lambda a, b: a.version == b.version and a.overlaps(b)
|
|
111
|
+
return {'selected_bridge_foreign_docker': any(intersects(a, b) for a in own for b in foreign),
|
|
112
|
+
'selected_bridge_host': any(intersects(a, ipaddress.ip_network(b['subnet'])) for a in own for b in others),
|
|
113
|
+
'pool_selected_bridge': any(intersects(proposed, item) for item in own),
|
|
114
|
+
'pool_foreign_docker': any(intersects(proposed, item) for item in foreign),
|
|
115
|
+
'pool_host_routes': any(intersects(proposed, ipaddress.ip_network(item['subnet'])) for item in host_rows
|
|
116
|
+
if item['kind'] == 'route'),
|
|
117
|
+
'pool_host_addresses': any(intersects(proposed, ipaddress.ip_network(item['subnet'])) for item in host_rows
|
|
118
|
+
if item['kind'] == 'address'),
|
|
119
|
+
'default_gateway_destinations': 'unverified'}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def port_bindings():
|
|
123
|
+
# Docker's publish filter matches the container port, so it can miss host 500 -> container 999.
|
|
124
|
+
rows = decode(run(['docker', 'ps', '--no-trunc', '--format', PORT_FORMAT]), lines=True)
|
|
125
|
+
result, observed = [], set()
|
|
126
|
+
for row in rows:
|
|
127
|
+
if (set(row) != {'id', 'project', 'service', 'directory', 'ports'}
|
|
128
|
+
or not re.fullmatch(CID, row['id']) or row['id'] in observed or not isinstance(row['ports'], str)):
|
|
129
|
+
raise NetworkFailure('network_invalid')
|
|
130
|
+
observed.add(row['id'])
|
|
131
|
+
bindings = {}
|
|
132
|
+
for entry in row['ports'].split(', '):
|
|
133
|
+
if '->' not in entry or not entry.endswith('/udp'):
|
|
134
|
+
continue
|
|
135
|
+
try:
|
|
136
|
+
host, target = entry.rsplit('->', 1)
|
|
137
|
+
if not re.fullmatch(r'[0-9]+(?:-[0-9]+)?/udp', target):
|
|
138
|
+
raise ValueError()
|
|
139
|
+
ports = host.rsplit(':', 1)[1].split('-')
|
|
140
|
+
if not 1 <= len(ports) <= 2 or any(not re.fullmatch(r'[0-9]+', x) for x in ports):
|
|
141
|
+
raise ValueError()
|
|
142
|
+
low, high = int(ports[0]), int(ports[-1])
|
|
143
|
+
if not 0 < low <= high <= 65535:
|
|
144
|
+
raise ValueError()
|
|
145
|
+
except (ValueError, IndexError):
|
|
146
|
+
raise NetworkFailure('network_invalid') from None
|
|
147
|
+
for port in ('500', '4500'):
|
|
148
|
+
if low <= int(port) <= high:
|
|
149
|
+
bindings.setdefault(port + '/udp', []).append({'HostPort': port})
|
|
150
|
+
if bindings:
|
|
151
|
+
result.append({**row, 'ports': bindings})
|
|
152
|
+
return sorted(result, key=lambda row: row['id'])
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Optional fixed DigitalOcean metadata corroboration, without provider management or discovery."""
|
|
2
|
+
import ipaddress
|
|
3
|
+
import re
|
|
4
|
+
import socket
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from network_host import host_binding
|
|
8
|
+
from network_projection import NetworkFailure
|
|
9
|
+
|
|
10
|
+
PATHS = {'droplet_id': '/metadata/v1/id', 'region': '/metadata/v1/region',
|
|
11
|
+
'public_ipv4': '/metadata/v1/interfaces/public/0/ipv4/address',
|
|
12
|
+
'private_ipv4': '/metadata/v1/interfaces/private/0/ipv4/address',
|
|
13
|
+
'netmask': '/metadata/v1/interfaces/private/0/ipv4/netmask'}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def fetch(path):
|
|
17
|
+
if path not in PATHS.values():
|
|
18
|
+
raise NetworkFailure('invalid_scope')
|
|
19
|
+
deadline, raw = time.monotonic() + 2, bytearray()
|
|
20
|
+
try:
|
|
21
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as connection:
|
|
22
|
+
connection.settimeout(2)
|
|
23
|
+
connection.connect(('169.254.169.254', 80))
|
|
24
|
+
remaining = deadline - time.monotonic()
|
|
25
|
+
if remaining <= 0:
|
|
26
|
+
return None
|
|
27
|
+
connection.settimeout(remaining)
|
|
28
|
+
request = 'GET ' + path + ' HTTP/1.0\r\nHost: 169.254.169.254\r\nConnection: close\r\n\r\n'
|
|
29
|
+
connection.sendall(request.encode('ascii'))
|
|
30
|
+
while True:
|
|
31
|
+
remaining = deadline - time.monotonic()
|
|
32
|
+
if remaining <= 0:
|
|
33
|
+
return None
|
|
34
|
+
connection.settimeout(remaining)
|
|
35
|
+
data = connection.recv(1024)
|
|
36
|
+
if not data:
|
|
37
|
+
break
|
|
38
|
+
raw.extend(data)
|
|
39
|
+
if len(raw) > 8192 + 128:
|
|
40
|
+
return None
|
|
41
|
+
return payload(bytes(raw))
|
|
42
|
+
except (OSError, UnicodeError, ValueError):
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def payload(raw):
|
|
47
|
+
try:
|
|
48
|
+
header, body = raw.split(b'\r\n\r\n', 1)
|
|
49
|
+
lines = header.split(b'\r\n')
|
|
50
|
+
if (len(header) > 8192 or len(body) > 128 or len(lines) > 17
|
|
51
|
+
or not re.fullmatch(rb'HTTP/1\.[01] 200(?: [\x20-\x7e]*)?', lines[0])):
|
|
52
|
+
return None
|
|
53
|
+
fields = [line.split(b':', 1) for line in lines[1:]]
|
|
54
|
+
headers = {key.lower(): value.strip() for key, value in fields}
|
|
55
|
+
if (len(headers) != len(fields) or b'transfer-encoding' in headers
|
|
56
|
+
or headers.get(b'content-encoding', b'identity') != b'identity'):
|
|
57
|
+
return None
|
|
58
|
+
length = headers.get(b'content-length')
|
|
59
|
+
if length is not None and (not re.fullmatch(rb'[0-9]{1,3}', length) or int(length) != len(body)):
|
|
60
|
+
return None
|
|
61
|
+
return body.decode('ascii').strip()
|
|
62
|
+
except (ValueError, UnicodeError):
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def observe(host, rows, provider):
|
|
67
|
+
addresses = host_binding(host, rows)
|
|
68
|
+
result = {'binding': 'matched_local_address', 'provider': provider, 'provider_identity': None,
|
|
69
|
+
'metadata_status': 'not_requested' if provider == 'none' else 'unavailable'}
|
|
70
|
+
if provider == 'none':
|
|
71
|
+
return result
|
|
72
|
+
values = {key: fetch(path) for key, path in PATHS.items()}
|
|
73
|
+
if any(value is None for value in values.values()):
|
|
74
|
+
return result
|
|
75
|
+
try:
|
|
76
|
+
if (not re.fullmatch(r'[1-9][0-9]{0,19}', values['droplet_id'])
|
|
77
|
+
or not re.fullmatch(r'[a-z0-9-]{1,32}', values['region'])):
|
|
78
|
+
raise ValueError()
|
|
79
|
+
public = str(ipaddress.IPv4Address(values['public_ipv4']))
|
|
80
|
+
private = str(ipaddress.IPv4Address(values['private_ipv4']))
|
|
81
|
+
mask = ipaddress.IPv4Network('0.0.0.0/' + values.pop('netmask'))
|
|
82
|
+
subnet = str(ipaddress.IPv4Network(private + '/' + str(mask.prefixlen), strict=False))
|
|
83
|
+
except (ValueError, TypeError):
|
|
84
|
+
result['metadata_status'] = 'unverified'
|
|
85
|
+
return result
|
|
86
|
+
if (public not in addresses or not any(row['kind'] == 'address' and row['local'] == private
|
|
87
|
+
and row['subnet'] == subnet for row in rows)):
|
|
88
|
+
raise NetworkFailure('host_binding_mismatch')
|
|
89
|
+
values['private_subnet'] = subnet
|
|
90
|
+
result.update(provider_identity=values, metadata_status='matched')
|
|
91
|
+
return result
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Closed observations for one owned bridge and a proposed namespace-only VPN pool."""
|
|
2
|
+
import ipaddress
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from runtime_projection import READER_VERSION, PROJECT, SERVICE, SHA, CID
|
|
6
|
+
|
|
7
|
+
SCHEMA = 'gowalk-cicd/backend-network-diagnostics.v3'
|
|
8
|
+
FAILURES = frozenset({'invalid_scope', 'network_absent', 'network_ambiguous', 'network_scope_mismatch',
|
|
9
|
+
'network_changed', 'network_invalid', 'build_mismatch', 'host_binding_mismatch', 'command_timeout',
|
|
10
|
+
'output_limit', 'command_failed', 'transport_failure', 'remote_invalid', 'artifact_write_failed',
|
|
11
|
+
'network_reader_unavailable', 'unclassified'})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NetworkFailure(Exception):
|
|
15
|
+
def __init__(self, code):
|
|
16
|
+
self.code = code if code in FAILURES else 'unclassified'
|
|
17
|
+
super().__init__(self.code)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def scope(app, service, expected, network, pool, vpn_service, host, provider):
|
|
21
|
+
fields = ((app, PROJECT), (service, SERVICE), (expected, SHA), (network, SERVICE),
|
|
22
|
+
(vpn_service, SERVICE), (host, r'[A-Za-z0-9][A-Za-z0-9.-]{0,252}'))
|
|
23
|
+
if any(not isinstance(value, str) or not re.fullmatch(pattern, value) for value, pattern in fields):
|
|
24
|
+
raise NetworkFailure('invalid_scope')
|
|
25
|
+
try:
|
|
26
|
+
candidate = ipaddress.IPv4Network(pool, strict=True)
|
|
27
|
+
except (ValueError, TypeError):
|
|
28
|
+
raise NetworkFailure('invalid_scope') from None
|
|
29
|
+
private = ('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16')
|
|
30
|
+
if (candidate.prefixlen != 24 or not any(candidate.subnet_of(ipaddress.IPv4Network(x)) for x in private)
|
|
31
|
+
or provider not in ('none', 'digitalocean')):
|
|
32
|
+
raise NetworkFailure('invalid_scope')
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def envelope(app, service, expected, network, pool, vpn_service, host, provider, failure='', *,
|
|
36
|
+
reader_version=READER_VERSION):
|
|
37
|
+
return {'schema': SCHEMA, 'reader_version': reader_version, 'ok': not failure, 'failure': failure,
|
|
38
|
+
'project': app, 'build_service': service, 'expected_build_sha': expected, 'network_key': network,
|
|
39
|
+
'proposed_pool': pool, 'vpn_service': vpn_service, 'expected_host': host, 'provider': provider}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def closed(value, fields):
|
|
43
|
+
if not isinstance(value, dict) or set(value) != set(fields):
|
|
44
|
+
raise NetworkFailure('remote_invalid')
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def bridge_record(row, app, network):
|
|
48
|
+
closed(row, ('network_id', 'name', 'driver', 'project', 'key', 'bridge', 'ipam'))
|
|
49
|
+
if (not isinstance(row['network_id'], str) or not re.fullmatch(CID, row['network_id'])
|
|
50
|
+
or row['project'] != app or row['key'] != network or row['driver'] != 'bridge'
|
|
51
|
+
or not isinstance(row['name'], str) or not re.fullmatch(r'[A-Za-z0-9_.-]{1,256}', row['name'])
|
|
52
|
+
or not isinstance(row['bridge'], str) or not re.fullmatch(r'[A-Za-z0-9_.-]{1,15}', row['bridge'])
|
|
53
|
+
or not isinstance(row['ipam'], list) or not 1 <= len(row['ipam']) <= 16):
|
|
54
|
+
raise NetworkFailure('remote_invalid')
|
|
55
|
+
for item in row['ipam']:
|
|
56
|
+
closed(item, ('subnet', 'gateway'))
|
|
57
|
+
if any(not isinstance(value, str) for value in item.values()):
|
|
58
|
+
raise NetworkFailure('remote_invalid')
|
|
59
|
+
try:
|
|
60
|
+
subnet = ipaddress.ip_network(item['subnet'], strict=True)
|
|
61
|
+
gateway = ipaddress.ip_address(item['gateway'])
|
|
62
|
+
if gateway not in subnet:
|
|
63
|
+
raise ValueError()
|
|
64
|
+
except (ValueError, TypeError):
|
|
65
|
+
raise NetworkFailure('remote_invalid') from None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def observation_records(result):
|
|
69
|
+
closed(result['overlap'], ('selected_bridge_foreign_docker', 'selected_bridge_host',
|
|
70
|
+
'pool_selected_bridge', 'pool_foreign_docker', 'pool_host_routes', 'pool_host_addresses',
|
|
71
|
+
'default_gateway_destinations'))
|
|
72
|
+
if (result['overlap']['default_gateway_destinations'] != 'unverified'
|
|
73
|
+
or any(type(value) is not bool for key, value in result['overlap'].items()
|
|
74
|
+
if key != 'default_gateway_destinations')):
|
|
75
|
+
raise NetworkFailure('remote_invalid')
|
|
76
|
+
closed(result['udp_ports'], ('500', '4500'))
|
|
77
|
+
for row in result['udp_ports'].values():
|
|
78
|
+
closed(row, ('status', 'host_listener_observed', 'docker_binding_observed'))
|
|
79
|
+
if (row['status'] not in ('free', 'app_vpn', 'foreign', 'unknown')
|
|
80
|
+
or any(type(row[key]) is not bool for key in ('host_listener_observed', 'docker_binding_observed'))):
|
|
81
|
+
raise NetworkFailure('remote_invalid')
|
|
82
|
+
observed = row['host_listener_observed'] or row['docker_binding_observed']
|
|
83
|
+
if ((row['status'] == 'free') == observed
|
|
84
|
+
or row['status'] in ('app_vpn', 'foreign') and not row['docker_binding_observed']):
|
|
85
|
+
raise NetworkFailure('remote_invalid')
|
|
86
|
+
closed(result['namespace'], ('docker_linux_rootful', 'host_ipv4_forwarding', 'xfrm_read',
|
|
87
|
+
'netfilter_nat_read', 'selected_bridge_masquerade', 'vpn_settings'))
|
|
88
|
+
for key in ('docker_linux_rootful', 'host_ipv4_forwarding', 'selected_bridge_masquerade'):
|
|
89
|
+
if result['namespace'][key] is not None and type(result['namespace'][key]) is not bool:
|
|
90
|
+
raise NetworkFailure('remote_invalid')
|
|
91
|
+
for key in ('xfrm_read', 'netfilter_nat_read'):
|
|
92
|
+
if result['namespace'][key] not in ('available', 'unavailable', 'unverified'):
|
|
93
|
+
raise NetworkFailure('remote_invalid')
|
|
94
|
+
if result['namespace']['vpn_settings'] not in ('absent', 'isolated_net_admin', 'other', 'unverified'):
|
|
95
|
+
raise NetworkFailure('remote_invalid')
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def host_records(result, provider):
|
|
99
|
+
closed(result['host'], ('binding', 'provider', 'provider_identity', 'metadata_status'))
|
|
100
|
+
row = result['host']
|
|
101
|
+
if (row['binding'] != 'matched_local_address' or row['provider'] != provider
|
|
102
|
+
or row['metadata_status'] not in ('matched', 'unavailable', 'unverified', 'not_requested')):
|
|
103
|
+
raise NetworkFailure('remote_invalid')
|
|
104
|
+
identity = row['provider_identity']
|
|
105
|
+
if identity is not None:
|
|
106
|
+
closed(identity, ('droplet_id', 'region', 'public_ipv4', 'private_ipv4', 'private_subnet'))
|
|
107
|
+
if any(not isinstance(value, str) for value in identity.values()):
|
|
108
|
+
raise NetworkFailure('remote_invalid')
|
|
109
|
+
if (not re.fullmatch(r'[1-9][0-9]{0,19}', str(identity['droplet_id']))
|
|
110
|
+
or not isinstance(identity['region'], str) or not re.fullmatch(r'[a-z0-9-]{1,32}', identity['region'])):
|
|
111
|
+
raise NetworkFailure('remote_invalid')
|
|
112
|
+
try:
|
|
113
|
+
ipaddress.IPv4Address(identity['public_ipv4'])
|
|
114
|
+
ipaddress.IPv4Address(identity['private_ipv4'])
|
|
115
|
+
ipaddress.IPv4Network(identity['private_subnet'], strict=True)
|
|
116
|
+
except (ValueError, TypeError):
|
|
117
|
+
raise NetworkFailure('remote_invalid') from None
|
|
118
|
+
if (row['metadata_status'] == 'matched') != (identity is not None):
|
|
119
|
+
raise NetworkFailure('remote_invalid')
|
|
120
|
+
if (provider == 'none') != (row['metadata_status'] == 'not_requested'):
|
|
121
|
+
raise NetworkFailure('remote_invalid')
|
|
122
|
+
closed(result['firewall'], ('reader', 'input_policy', 'forward_policy', 'udp_allow_rules', 'effective_ingress'))
|
|
123
|
+
row = result['firewall']
|
|
124
|
+
if (row['reader'] not in ('observed', 'unverified') or row['effective_ingress'] != 'unverified'
|
|
125
|
+
or any(row[key] not in ('ACCEPT', 'DROP', 'unverified') for key in ('input_policy', 'forward_policy'))):
|
|
126
|
+
raise NetworkFailure('remote_invalid')
|
|
127
|
+
closed(row['udp_allow_rules'], ('500', '4500'))
|
|
128
|
+
if any(type(value) is not bool for value in row['udp_allow_rules'].values()):
|
|
129
|
+
raise NetworkFailure('remote_invalid')
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def validate(result, app, service, expected, network, pool, vpn_service, host, provider):
|
|
133
|
+
scope(app, service, expected, network, pool, vpn_service, host, provider)
|
|
134
|
+
base = envelope(app, service, expected, network, pool, vpn_service, host, provider)
|
|
135
|
+
if (not isinstance(result, dict) or type(result.get('ok')) is not bool
|
|
136
|
+
or any(result.get(key) != value for key, value in base.items() if key not in ('ok', 'failure'))):
|
|
137
|
+
raise NetworkFailure('remote_invalid')
|
|
138
|
+
if not result['ok']:
|
|
139
|
+
if set(result) != set(base) or result.get('failure') not in FAILURES:
|
|
140
|
+
raise NetworkFailure('remote_invalid')
|
|
141
|
+
return result
|
|
142
|
+
closed(result, set(base) | {'observed_build_sha', 'bridge', 'overlap', 'udp_ports', 'host', 'namespace',
|
|
143
|
+
'firewall', 'observed_at'})
|
|
144
|
+
if (result['failure'] or result['observed_build_sha'] != expected
|
|
145
|
+
or not isinstance(result['observed_at'], str)
|
|
146
|
+
or not re.fullmatch(r'\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ', result['observed_at'])):
|
|
147
|
+
raise NetworkFailure('remote_invalid')
|
|
148
|
+
bridge_record(result['bridge'], app, network)
|
|
149
|
+
observation_records(result)
|
|
150
|
+
host_records(result, provider)
|
|
151
|
+
return result
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Read stable owned bridge and namespace-only VPN prerequisites; never mutate host networking."""
|
|
2
|
+
import datetime
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import network_host
|
|
7
|
+
import network_inventory
|
|
8
|
+
import network_metadata
|
|
9
|
+
import runtime_readback
|
|
10
|
+
from network_projection import NetworkFailure, envelope, scope, validate
|
|
11
|
+
from runtime_projection import RuntimeFailure
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def application(app, service, expected):
|
|
15
|
+
try:
|
|
16
|
+
identifiers = runtime_readback.containers(app)
|
|
17
|
+
rows = runtime_readback.inspect(app, identifiers)
|
|
18
|
+
except RuntimeFailure as error:
|
|
19
|
+
mapping = {'runtime_absent': 'network_absent', 'runtime_ambiguous': 'network_ambiguous',
|
|
20
|
+
'runtime_scope_mismatch': 'network_scope_mismatch', 'runtime_changed': 'network_changed',
|
|
21
|
+
'runtime_invalid': 'network_invalid'}
|
|
22
|
+
raise NetworkFailure(mapping.get(error.code, error.code)) from None
|
|
23
|
+
selected = [row for row in rows if row['service'] == service and row['running']]
|
|
24
|
+
if len(selected) != 1 or selected[0]['binding'].get('BUILD_SHA') != expected:
|
|
25
|
+
raise NetworkFailure('build_mismatch')
|
|
26
|
+
return rows
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def snapshot(app, service, expected):
|
|
30
|
+
return {'application': application(app, service, expected), 'networks': network_inventory.networks(),
|
|
31
|
+
'host': network_inventory.host_networks(), 'bindings': network_inventory.port_bindings(),
|
|
32
|
+
'listeners': network_host.listeners()}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def collect(app, service, expected, network, pool, vpn_service, host, provider):
|
|
36
|
+
scope(app, service, expected, network, pool, vpn_service, host, provider)
|
|
37
|
+
network_host.local_docker()
|
|
38
|
+
original = snapshot(app, service, expected)
|
|
39
|
+
bridge = network_inventory.selected(original['networks'], app, network)
|
|
40
|
+
host_result = network_metadata.observe(host, original['host'], provider)
|
|
41
|
+
firewall, masquerade, netfilter = network_host.firewall(bridge)
|
|
42
|
+
namespace = network_host.namespace(original['application'], app, vpn_service, bridge)
|
|
43
|
+
namespace.update(netfilter_nat_read=netfilter, selected_bridge_masquerade=masquerade)
|
|
44
|
+
if snapshot(app, service, expected) != original:
|
|
45
|
+
raise NetworkFailure('network_changed')
|
|
46
|
+
result = dict(envelope(app, service, expected, network, pool, vpn_service, host, provider),
|
|
47
|
+
observed_build_sha=expected, bridge=bridge, host=host_result, firewall=firewall, namespace=namespace,
|
|
48
|
+
overlap=network_inventory.overlap(original['networks'], bridge, original['host'], pool),
|
|
49
|
+
udp_ports=network_host.udp_ports(app, vpn_service, original['bindings'], original['listeners']),
|
|
50
|
+
observed_at=datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))
|
|
51
|
+
return validate(result, app, service, expected, network, pool, vpn_service, host, provider)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main():
|
|
55
|
+
arguments = (sys.argv[1:] + [''] * 8)[:8]
|
|
56
|
+
try:
|
|
57
|
+
result = collect(*arguments)
|
|
58
|
+
except Exception as error:
|
|
59
|
+
result = envelope(*arguments, failure=error.code if isinstance(error, NetworkFailure) else 'unclassified')
|
|
60
|
+
print(json.dumps(result, separators=(',', ':')))
|
|
61
|
+
return 0 if result['ok'] else 1
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == '__main__':
|
|
65
|
+
raise SystemExit(main())
|
|
@@ -80,6 +80,8 @@ def authorize(owner, project, policy, may_claim):
|
|
|
80
80
|
|
|
81
81
|
|
|
82
82
|
def helper_command(words):
|
|
83
|
+
if words[:2] == ['gowalk-backend', 'network-diagnostics']:
|
|
84
|
+
return network_command(words)
|
|
83
85
|
if len(words) == 5 and words[:2] == ['gowalk-backend', 'runtime-diagnostics']:
|
|
84
86
|
from runtime_projection import RuntimeFailure, scope
|
|
85
87
|
try:
|
|
@@ -114,6 +116,17 @@ def helper_command(words):
|
|
|
114
116
|
raise ValueError('ssh_operation_refused')
|
|
115
117
|
|
|
116
118
|
|
|
119
|
+
def network_command(words):
|
|
120
|
+
from network_projection import NetworkFailure, scope
|
|
121
|
+
if len(words) != 10:
|
|
122
|
+
raise ValueError('ssh_arguments_refused')
|
|
123
|
+
try:
|
|
124
|
+
scope(*words[2:])
|
|
125
|
+
except NetworkFailure:
|
|
126
|
+
raise ValueError('ssh_arguments_refused') from None
|
|
127
|
+
return words[2], False, [sys.executable, str(HELPERS / 'network_readback.py'), *words[2:]]
|
|
128
|
+
|
|
129
|
+
|
|
117
130
|
def transfer_command(words):
|
|
118
131
|
if len(words) == 3 and words[:2] == ['mkdir', '-p']:
|
|
119
132
|
project, path = scoped_path(words[2])
|
package/docs/BACKEND_SSH_GATE.md
CHANGED
|
@@ -43,6 +43,7 @@ The entry permits only:
|
|
|
43
43
|
- the standard private runtime environment and edge-input receive envelopes;
|
|
44
44
|
- the standard backend deploy invocation, redirected to the host-owned released helper;
|
|
45
45
|
- the standard bounded identity-diagnostic invocation, also redirected to the released helper;
|
|
46
|
+
- `gowalk-backend network-diagnostics` with fixed owned project, API build, bridge key and namespace-only VPN pool;
|
|
46
47
|
- `gowalk-backend native-deploy <project>` for an existing [root-profiled native service](BACKEND_NATIVE.md).
|
|
47
48
|
|
|
48
49
|
It refuses arbitrary shell commands, interactive shells, rsync sender/download operations, traversal,
|
|
@@ -89,6 +90,59 @@ malformed result refuses proof. A replay is another read and makes no mutation.
|
|
|
89
90
|
records never establish database ownership or resources, and an absent source `db` container alone proves no
|
|
90
91
|
shared-database substitution.
|
|
91
92
|
|
|
93
|
+
## Owned bridge and VPN prerequisite readback
|
|
94
|
+
|
|
95
|
+
`operation: network-diagnostics` is a read-only operation. The network allocator remains retired.
|
|
96
|
+
Select `diagnostic-service` (the existing API), `diagnostic-network` (the existing Compose bridge key,
|
|
97
|
+
default `default`), `diagnostic-vpn-service` (default `vpn`), `expected-build-sha`, and `diagnostic-pool`:
|
|
98
|
+
a strict RFC1918 IPv4 `/24` proposed only for the VPN container namespace. It creates no address reservation,
|
|
99
|
+
network, route, firewall rule, container, certificate or service. Do not use this pool as Compose IPAM.
|
|
100
|
+
The generated workflow transports `diagnostic-build-sha` separately so a workflow-only commit can inspect
|
|
101
|
+
the API build actually deployed, rather than claiming its own checkout SHA is running.
|
|
102
|
+
|
|
103
|
+
The existing pinned CI identity sends only this fixed command:
|
|
104
|
+
|
|
105
|
+
```text
|
|
106
|
+
gowalk-backend network-diagnostics <project> <api-service> <deployed-SHA> <bridge-key> <pool> <vpn-service> <bound-host> <provider>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The trusted gate checks existing panel/project ownership and the global freeze, then invokes only its own
|
|
110
|
+
released `network_readback.py`. It accepts no uploaded Python or app-mirror helper. Updating an app action
|
|
111
|
+
alone cannot install this host operation: install the integrity-verified published runtime first. A missing
|
|
112
|
+
host reader returns `network_reader_unavailable` with no host reader version; it is not a network verdict.
|
|
113
|
+
|
|
114
|
+
The closed `gowalk-cicd/backend-network-diagnostics.v3` result is retained as
|
|
115
|
+
`backend-network-diagnostics-<job>-<attempt>/result.json`. `ok: true` means the bounded read completed;
|
|
116
|
+
it never means a VPN is provisioned, traffic is reachable, a pool is globally unused or a tunnel works.
|
|
117
|
+
The reader binds the existing API's `BUILD_SHA`, checks its exact project/service/directory labels, and
|
|
118
|
+
rechecks application identity, selected network inventory, host addresses/routes, Docker UDP bindings and
|
|
119
|
+
socket observations before returning. A changed or ambiguous observation refuses the result.
|
|
120
|
+
|
|
121
|
+
- `bridge` reports only the selected app bridge's actual network ID/name, ownership key, interface and IPAM.
|
|
122
|
+
- `overlap` distinguishes selected-bridge foreign/host overlaps and proposed-pool selected/foreign/host
|
|
123
|
+
overlaps. Default-gateway destinations remain `unverified`; route snapshots cannot prove remote routes absent.
|
|
124
|
+
- `udp_ports` classifies fixed UDP ports `500` and `4500` as `free`, exact `app_vpn`, `foreign` or `unknown`.
|
|
125
|
+
Reuse requires the exact app/vpn-service/directory binding. A host listener not attributable to Docker's
|
|
126
|
+
proxy stays unknown. This is a timestamped observation, not a port reservation; actual deployment can race.
|
|
127
|
+
- `host.binding` requires the bound host's IPv4 address(es) to match local host addresses. Optional
|
|
128
|
+
`diagnostic-provider: digitalocean` corroborates fixed metadata with those live interfaces. Metadata
|
|
129
|
+
unavailable/unverified states remain explicit; metadata does not establish VPC, firewall or routing authority.
|
|
130
|
+
- `namespace` reports local rootful Linux Docker, existing host forwarding, read-only XFRM/netfilter
|
|
131
|
+
availability and the selected bridge's observed masquerade rule. Existing VPN settings are observed only
|
|
132
|
+
for this app's exact service. `isolated_net_admin` requires that bridge, no privileged mode, dropping ALL,
|
|
133
|
+
only NET_ADMIN plus optional NET_BIND_SERVICE/CHOWN/SETUID/SETGID for bootstrap and privilege dropping,
|
|
134
|
+
and namespace-only forwarding/redirect settings. This observes grants, not that a daemon actually dropped privileges.
|
|
135
|
+
An absent VPN reports `absent`; these observations do not test creating a namespace or applying capabilities.
|
|
136
|
+
- `firewall` reports observed INPUT/FORWARD policies and explicit UDP ACCEPT rules. Effective ingress
|
|
137
|
+
remains `unverified`: chained rules, nftables and upstream/provider firewalls need actual external traffic proof.
|
|
138
|
+
|
|
139
|
+
Foreign network ownership/configuration, process identities, raw firewall rules, environment values and
|
|
140
|
+
private bindings never leave the host. Foreign subnet data is used privately only to calculate overlap flags.
|
|
141
|
+
The reader executes no container code, reads no logs and contacts no provider management API.
|
|
142
|
+
No retry, downgrade, unrestricted SSH or historical inventory can replace a refused current read.
|
|
143
|
+
Preserve bridge and database identities, assess the observations within their stated scope, then separately
|
|
144
|
+
verify the actual namespaced gateway configuration, IKEv2 negotiation and encrypted forwarded traffic.
|
|
145
|
+
|
|
92
146
|
## Install a published host runtime
|
|
93
147
|
|
|
94
148
|
Use the published package's `backend-action/install_runtime.py` with its npm archive, the registry's exact
|
|
@@ -96,8 +150,8 @@ Use the published package's `backend-action/install_runtime.py` with its npm arc
|
|
|
96
150
|
For example, after transferring those verified public files to task-owned host scratch:
|
|
97
151
|
|
|
98
152
|
```sh
|
|
99
|
-
python3 install_runtime.py --archive gowalk-cicd-1.0.
|
|
100
|
-
--version 1.0.
|
|
153
|
+
python3 install_runtime.py --archive gowalk-cicd-1.0.192.tgz --integrity "$PACKAGE_INTEGRITY" \
|
|
154
|
+
--version 1.0.192 --owner robot --owner vpnrobot
|
|
101
155
|
```
|
|
102
156
|
|
|
103
157
|
The root-only installer verifies integrity/name/version again, installs only package-owned backend helpers under
|
|
@@ -105,6 +159,7 @@ The root-only installer verifies integrity/name/version again, installs only pac
|
|
|
105
159
|
`/root/.ssh/authorized_keys`. It refuses missing/duplicate owners, nonrestricted entries, untrusted paths and
|
|
106
160
|
changed existing release bytes; it never provisions keys or changes another entry. Exact replay verifies the
|
|
107
161
|
release and leaves keys unchanged. A fixed receipt confirms the version and owners without key contents.
|
|
162
|
+
The established `/usr/bin/python3 -B` spelling is accepted and preserves `-B`; other interpreter flags refuse.
|
|
108
163
|
It changes no app source, runtime profile, container, database, service timer or deployment freeze. Preserve the
|
|
109
164
|
previous release directory; this is ordinary released-helper installation, not app-state recovery.
|
|
110
165
|
Verify a fresh scoped readback through each owner and keep that receipt with the exact npm integrity and release.
|
package/package.json
CHANGED
|
@@ -20,13 +20,38 @@ on:
|
|
|
20
20
|
inputs:
|
|
21
21
|
operation:
|
|
22
22
|
type: choice
|
|
23
|
-
description: Deploy or read scoped
|
|
23
|
+
description: Deploy or read scoped diagnostics without changing the running app
|
|
24
24
|
default: deploy
|
|
25
|
-
options: [deploy, identity-diagnostics]
|
|
25
|
+
options: [deploy, identity-diagnostics, network-diagnostics]
|
|
26
26
|
diagnostic-since:
|
|
27
27
|
type: string
|
|
28
28
|
description: For diagnostics, UTC YYYY-MM-DDTHH:MM:SSZ within the previous 24 hours
|
|
29
29
|
required: false
|
|
30
|
+
diagnostic-service:
|
|
31
|
+
type: string
|
|
32
|
+
description: Existing API Compose service for network diagnostics
|
|
33
|
+
default: api
|
|
34
|
+
diagnostic-build-sha:
|
|
35
|
+
type: string
|
|
36
|
+
description: Exact currently deployed API SHA for network diagnostics
|
|
37
|
+
required: false
|
|
38
|
+
diagnostic-pool:
|
|
39
|
+
type: string
|
|
40
|
+
description: Proposed namespace-only VPN private IPv4 /24
|
|
41
|
+
required: false
|
|
42
|
+
diagnostic-network:
|
|
43
|
+
type: string
|
|
44
|
+
description: Existing owned Compose bridge key
|
|
45
|
+
default: default
|
|
46
|
+
diagnostic-vpn-service:
|
|
47
|
+
type: string
|
|
48
|
+
description: Exact app service whose UDP bindings may be reused
|
|
49
|
+
default: vpn
|
|
50
|
+
diagnostic-provider:
|
|
51
|
+
type: choice
|
|
52
|
+
description: Optional fixed metadata corroboration
|
|
53
|
+
options: [none, digitalocean]
|
|
54
|
+
default: none
|
|
30
55
|
|
|
31
56
|
permissions:
|
|
32
57
|
contents: read
|
|
@@ -58,6 +83,11 @@ jobs:
|
|
|
58
83
|
with:
|
|
59
84
|
operation: ${{ inputs.operation || 'deploy' }}
|
|
60
85
|
diagnostic-since: ${{ inputs.diagnostic-since }}
|
|
86
|
+
diagnostic-service: ${{ inputs.diagnostic-service || 'api' }}
|
|
87
|
+
diagnostic-network: ${{ inputs.diagnostic-network || 'default' }}
|
|
88
|
+
diagnostic-pool: ${{ inputs.diagnostic-pool }}
|
|
89
|
+
diagnostic-vpn-service: ${{ inputs.diagnostic-vpn-service || 'vpn' }}
|
|
90
|
+
diagnostic-provider: ${{ inputs.diagnostic-provider || 'none' }}
|
|
61
91
|
ssh-key: ${{ secrets.BACKEND_DEPLOY_SSH_KEY }}
|
|
62
92
|
known-hosts: ${{ vars.BACKEND_DEPLOY_KNOWN_HOSTS }}
|
|
63
93
|
host: ${{ vars.BACKEND_DEPLOY_HOST }}
|
|
@@ -68,7 +98,9 @@ jobs:
|
|
|
68
98
|
health-path: ${{ vars.BACKEND_HEALTH_PATH || '/health' }}
|
|
69
99
|
public-health-url: ${{ vars.BACKEND_PUBLIC_HEALTH_URL }}
|
|
70
100
|
public-base-url: ${{ vars.BACKEND_PUBLIC_BASE_URL }}
|
|
71
|
-
expected-build-sha:
|
|
101
|
+
expected-build-sha: >-
|
|
102
|
+
${{ (inputs.operation == 'network-diagnostics' && inputs.diagnostic-build-sha)
|
|
103
|
+
|| (vars.BACKEND_PUBLIC_HEALTH_URL && github.sha) || '' }}
|
|
72
104
|
ingress-required: ${{ vars.BACKEND_INGRESS_REQUIRED || 'false' }}
|
|
73
105
|
ingress-token: ${{ secrets.BACKEND_INGRESS_TOKEN }}
|
|
74
106
|
runtime-env: ${{ secrets.BACKEND_RUNTIME_ENV }}
|
|
Binary file
|