gowalk-cicd 1.0.192 → 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 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 runtime-diagnostics.
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: Ignored; network-diagnostics was removed with the network allocator.
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
 
@@ -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())
@@ -3,7 +3,7 @@ import math
3
3
  import re
4
4
 
5
5
  SCHEMA = 'gowalk-cicd/backend-runtime-diagnostics.v1'
6
- READER_VERSION = '1.0.192'
6
+ READER_VERSION = '1.0.193'
7
7
  PROJECT = r'[a-z0-9][a-z0-9-]{0,79}'
8
8
  SERVICE = r'[a-z0-9][a-z0-9_-]{0,63}'
9
9
  SHA = r'[a-f0-9]{40}'
@@ -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])
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.192",
3
+ "version": "1.0.193",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,13 +20,38 @@ on:
20
20
  inputs:
21
21
  operation:
22
22
  type: choice
23
- description: Deploy or read scoped identity diagnostics without changing the running app
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: ${{ vars.BACKEND_PUBLIC_HEALTH_URL && github.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