gowalk-cicd 1.0.207 → 1.0.208
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 +4 -2
- package/backend-action/action.yml +1 -1
- package/backend-action/network_host.py +69 -0
- package/backend-action/network_inventory.py +4 -2
- package/backend-action/network_projection.py +25 -8
- package/backend-action/network_readback.py +12 -4
- package/docs/BACKEND_SSH_GATE.md +8 -3
- package/package.json +1 -1
- package/templates/deploy-backend.yml +1 -1
package/README.md
CHANGED
|
@@ -1390,10 +1390,12 @@ Identity failure logs and source Compose files do not establish this applied sta
|
|
|
1390
1390
|
### Read-only bridge and VPN prerequisite diagnostics
|
|
1391
1391
|
|
|
1392
1392
|
Backend Deploy also offers `operation: network-diagnostics`, with an exact `diagnostic-service`, deployed
|
|
1393
|
-
`diagnostic-build-sha`, owned `diagnostic-network`, proposed namespace-only private IPv4 `/24` in
|
|
1393
|
+
`diagnostic-build-sha`, owned `diagnostic-network`, proposed namespace-only private IPv4 `/24` or IPv6 ULA `/64` in
|
|
1394
1394
|
`diagnostic-pool`, exact `diagnostic-vpn-service`, and optional `diagnostic-provider: digitalocean`.
|
|
1395
1395
|
It reports the owned bridge/IPAM, observed pool conflicts, UDP 500/4500 ownership, bound host identity and
|
|
1396
|
-
kernel/firewall facts in a closed v3 artifact
|
|
1396
|
+
kernel/firewall facts in a closed v3 artifact for IPv4 and v4 for IPv6. The IPv6 result also distinguishes
|
|
1397
|
+
host and owned-gateway default-route and forwarding observations; actual external connectivity stays unverified.
|
|
1398
|
+
The allocator stays retired; a completed read is never
|
|
1397
1399
|
deployment, port reservation, globally unused address space or tunnel proof. Effective firewall ingress and
|
|
1398
1400
|
unobserved capabilities remain unverified. The [trusted-host procedure](docs/BACKEND_SSH_GATE.md#owned-bridge-and-vpn-prerequisite-readback)
|
|
1399
1401
|
defines scope, retained evidence and the required host runtime adoption before this operation is available.
|
|
@@ -27,7 +27,7 @@ inputs:
|
|
|
27
27
|
required: false
|
|
28
28
|
default: default
|
|
29
29
|
diagnostic-pool:
|
|
30
|
-
description: Proposed strict private IPv4 /24 for a VPN
|
|
30
|
+
description: Proposed strict private IPv4 /24 or IPv6 ULA /64 for a VPN namespace; never a host allocation.
|
|
31
31
|
required: false
|
|
32
32
|
default: ""
|
|
33
33
|
diagnostic-vpn-service:
|
|
@@ -105,6 +105,75 @@ def local_docker():
|
|
|
105
105
|
raise NetworkFailure('network_scope_mismatch')
|
|
106
106
|
|
|
107
107
|
|
|
108
|
+
def _ipv6_default(raw):
|
|
109
|
+
if raw is None:
|
|
110
|
+
return 'unverified'
|
|
111
|
+
try:
|
|
112
|
+
rows = json.loads(raw)
|
|
113
|
+
if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
|
|
114
|
+
raise ValueError()
|
|
115
|
+
found = False
|
|
116
|
+
for row in rows:
|
|
117
|
+
if row.get('dst', 'default') not in ('default', '::/0'):
|
|
118
|
+
raise ValueError()
|
|
119
|
+
flags = row.get('flags', [])
|
|
120
|
+
if not isinstance(flags, list) or any(not isinstance(flag, str) for flag in flags):
|
|
121
|
+
raise ValueError()
|
|
122
|
+
kind = row.get('type', 'unicast')
|
|
123
|
+
if kind not in ('unicast', 'blackhole', 'unreachable', 'prohibit', 'throw'):
|
|
124
|
+
raise ValueError()
|
|
125
|
+
device = row.get('dev')
|
|
126
|
+
if kind == 'unicast' and (not isinstance(device, str) or not re.fullmatch(r'[A-Za-z0-9_.-]{1,15}', device)):
|
|
127
|
+
raise ValueError()
|
|
128
|
+
found |= kind == 'unicast' and device != 'lo' and 'linkdown' not in flags
|
|
129
|
+
return 'present' if found else 'absent'
|
|
130
|
+
except (ValueError, TypeError, UnicodeError):
|
|
131
|
+
return 'unverified'
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def ipv6_routes(containers, app, service, bridge, namespace_safe=False):
|
|
135
|
+
host = _ipv6_default(run(['ip', '-j', '-6', 'route', 'show', 'default'], optional=True))
|
|
136
|
+
host_forward = run(['cat', '/proc/sys/net/ipv6/conf/all/forwarding'], optional=True)
|
|
137
|
+
forwarding = (None if host_forward is None or host_forward.strip() not in (b'0', b'1')
|
|
138
|
+
else host_forward.strip() == b'1')
|
|
139
|
+
matches = [row for row in containers if row['service'] == service and row['running']]
|
|
140
|
+
if len(matches) > 1:
|
|
141
|
+
raise NetworkFailure('network_ambiguous')
|
|
142
|
+
if not matches:
|
|
143
|
+
gateway = 'not_running'
|
|
144
|
+
gateway_forwarding = None
|
|
145
|
+
elif not namespace_safe:
|
|
146
|
+
gateway, gateway_forwarding = 'unverified', None
|
|
147
|
+
else:
|
|
148
|
+
template = ('{"project":{{json (index .Config.Labels "com.docker.compose.project")}},'
|
|
149
|
+
'"service":{{json (index .Config.Labels "com.docker.compose.service")}},'
|
|
150
|
+
'"mode":{{json .HostConfig.NetworkMode}},"key":{{json .NetworkSettings.SandboxKey}}}')
|
|
151
|
+
raw = run(['docker', 'inspect', '--format', template, matches[0]['id']], optional=True)
|
|
152
|
+
try:
|
|
153
|
+
record = json.loads(raw) if raw else {}
|
|
154
|
+
if not isinstance(record, dict):
|
|
155
|
+
raise ValueError()
|
|
156
|
+
if record and (record.get('project') != app or record.get('service') != service
|
|
157
|
+
or record.get('mode') != bridge['name']):
|
|
158
|
+
raise NetworkFailure('network_changed')
|
|
159
|
+
path = record.get('key', '')
|
|
160
|
+
except (ValueError, UnicodeError):
|
|
161
|
+
path = ''
|
|
162
|
+
if not isinstance(path, str):
|
|
163
|
+
path = ''
|
|
164
|
+
if not re.fullmatch(r'/var/run/docker/netns/[0-9a-f]{12,64}', path):
|
|
165
|
+
gateway, gateway_forwarding = 'unverified', None
|
|
166
|
+
else:
|
|
167
|
+
entry = ['nsenter', '--net=' + path, '--']
|
|
168
|
+
raw = run([*entry, 'ip', '-j', '-6', 'route', 'show', 'default'], optional=True)
|
|
169
|
+
gateway = _ipv6_default(raw)
|
|
170
|
+
raw = run([*entry, 'cat', '/proc/sys/net/ipv6/conf/all/forwarding'], optional=True)
|
|
171
|
+
gateway_forwarding = None if raw is None or raw.strip() not in (b'0', b'1') else raw.strip() == b'1'
|
|
172
|
+
return {'host_ipv6_default_route': host, 'gateway_ipv6_default_route': gateway,
|
|
173
|
+
'host_ipv6_forwarding': forwarding, 'gateway_ipv6_forwarding': gateway_forwarding,
|
|
174
|
+
'ipv6_external_connectivity': 'unverified'}
|
|
175
|
+
|
|
176
|
+
|
|
108
177
|
def namespace(containers, app, vpn_service, bridge):
|
|
109
178
|
info = run(['docker', 'info', '--format', '{"os":{{json .OSType}},"security":{{json .SecurityOptions}}}'])
|
|
110
179
|
try:
|
|
@@ -79,9 +79,11 @@ def selected(rows, app, key):
|
|
|
79
79
|
'ipam': [{'subnet': x['Subnet'], 'gateway': x.get('Gateway', '')} for x in row['ipam']]}
|
|
80
80
|
|
|
81
81
|
|
|
82
|
-
def host_networks():
|
|
82
|
+
def host_networks(include_ipv6=False):
|
|
83
83
|
addresses = decode(run(['ip', '-j', 'address', 'show']))
|
|
84
84
|
routes = decode(run(['ip', '-j', '-4', 'route', 'show', 'table', 'all']))
|
|
85
|
+
if include_ipv6:
|
|
86
|
+
routes += decode(run(['ip', '-j', '-6', 'route', 'show', 'table', 'all']))
|
|
85
87
|
result = []
|
|
86
88
|
try:
|
|
87
89
|
for row in addresses:
|
|
@@ -102,7 +104,7 @@ def host_networks():
|
|
|
102
104
|
|
|
103
105
|
|
|
104
106
|
def overlap(rows, bridge, host_rows, pool):
|
|
105
|
-
proposed = ipaddress.
|
|
107
|
+
proposed = ipaddress.ip_network(pool, strict=True)
|
|
106
108
|
own = [ipaddress.ip_network(item['subnet']) for item in bridge['ipam']]
|
|
107
109
|
foreign = [ipaddress.ip_network(item['Subnet']) for row in rows if row['id'] != bridge['network_id']
|
|
108
110
|
for item in row['ipam'] or []]
|
|
@@ -5,6 +5,7 @@ import re
|
|
|
5
5
|
from runtime_projection import READER_VERSION, PROJECT, SERVICE, SHA, CID
|
|
6
6
|
|
|
7
7
|
SCHEMA = 'gowalk-cicd/backend-network-diagnostics.v3'
|
|
8
|
+
IPV6_SCHEMA = 'gowalk-cicd/backend-network-diagnostics.v4'
|
|
8
9
|
FAILURES = frozenset({'invalid_scope', 'network_absent', 'network_ambiguous', 'network_scope_mismatch',
|
|
9
10
|
'network_changed', 'network_invalid', 'build_mismatch', 'host_binding_mismatch', 'command_timeout',
|
|
10
11
|
'output_limit', 'command_failed', 'transport_failure', 'remote_invalid', 'artifact_write_failed',
|
|
@@ -23,18 +24,22 @@ def scope(app, service, expected, network, pool, vpn_service, host, provider):
|
|
|
23
24
|
if any(not isinstance(value, str) or not re.fullmatch(pattern, value) for value, pattern in fields):
|
|
24
25
|
raise NetworkFailure('invalid_scope')
|
|
25
26
|
try:
|
|
26
|
-
candidate = ipaddress.
|
|
27
|
+
candidate = ipaddress.ip_network(pool, strict=True)
|
|
27
28
|
except (ValueError, TypeError):
|
|
28
29
|
raise NetworkFailure('invalid_scope') from None
|
|
29
30
|
private = ('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16')
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
v4 = (candidate.version == 4 and candidate.prefixlen == 24
|
|
32
|
+
and any(candidate.subnet_of(ipaddress.IPv4Network(x)) for x in private))
|
|
33
|
+
v6 = (candidate.version == 6 and candidate.prefixlen == 64
|
|
34
|
+
and candidate.subnet_of(ipaddress.IPv6Network('fc00::/7')))
|
|
35
|
+
if not (v4 or v6) or provider not in ('none', 'digitalocean'):
|
|
32
36
|
raise NetworkFailure('invalid_scope')
|
|
33
37
|
|
|
34
38
|
|
|
35
39
|
def envelope(app, service, expected, network, pool, vpn_service, host, provider, failure='', *,
|
|
36
40
|
reader_version=READER_VERSION):
|
|
37
|
-
|
|
41
|
+
schema = IPV6_SCHEMA if isinstance(pool, str) and ':' in pool else SCHEMA
|
|
42
|
+
return {'schema': schema, 'reader_version': reader_version, 'ok': not failure, 'failure': failure,
|
|
38
43
|
'project': app, 'build_service': service, 'expected_build_sha': expected, 'network_key': network,
|
|
39
44
|
'proposed_pool': pool, 'vpn_service': vpn_service, 'expected_host': host, 'provider': provider}
|
|
40
45
|
|
|
@@ -65,7 +70,7 @@ def bridge_record(row, app, network):
|
|
|
65
70
|
raise NetworkFailure('remote_invalid') from None
|
|
66
71
|
|
|
67
72
|
|
|
68
|
-
def observation_records(result):
|
|
73
|
+
def observation_records(result, ipv6=False):
|
|
69
74
|
closed(result['overlap'], ('selected_bridge_foreign_docker', 'selected_bridge_host',
|
|
70
75
|
'pool_selected_bridge', 'pool_foreign_docker', 'pool_host_routes', 'pool_host_addresses',
|
|
71
76
|
'default_gateway_destinations'))
|
|
@@ -83,8 +88,12 @@ def observation_records(result):
|
|
|
83
88
|
if ((row['status'] == 'free') == observed
|
|
84
89
|
or row['status'] in ('app_vpn', 'foreign') and not row['docker_binding_observed']):
|
|
85
90
|
raise NetworkFailure('remote_invalid')
|
|
86
|
-
|
|
87
|
-
|
|
91
|
+
fields = {'docker_linux_rootful', 'host_ipv4_forwarding', 'xfrm_read',
|
|
92
|
+
'netfilter_nat_read', 'selected_bridge_masquerade', 'vpn_settings'}
|
|
93
|
+
if ipv6:
|
|
94
|
+
fields |= {'host_ipv6_default_route', 'gateway_ipv6_default_route', 'host_ipv6_forwarding',
|
|
95
|
+
'gateway_ipv6_forwarding', 'ipv6_external_connectivity'}
|
|
96
|
+
closed(result['namespace'], fields)
|
|
88
97
|
for key in ('docker_linux_rootful', 'host_ipv4_forwarding', 'selected_bridge_masquerade'):
|
|
89
98
|
if result['namespace'][key] is not None and type(result['namespace'][key]) is not bool:
|
|
90
99
|
raise NetworkFailure('remote_invalid')
|
|
@@ -93,6 +102,14 @@ def observation_records(result):
|
|
|
93
102
|
raise NetworkFailure('remote_invalid')
|
|
94
103
|
if result['namespace']['vpn_settings'] not in ('absent', 'isolated_net_admin', 'other', 'unverified'):
|
|
95
104
|
raise NetworkFailure('remote_invalid')
|
|
105
|
+
if ipv6:
|
|
106
|
+
row = result['namespace']
|
|
107
|
+
if (row['host_ipv6_default_route'] not in ('present', 'absent', 'unverified')
|
|
108
|
+
or row['gateway_ipv6_default_route'] not in ('present', 'absent', 'not_running', 'unverified')
|
|
109
|
+
or any(row[key] is not None and type(row[key]) is not bool
|
|
110
|
+
for key in ('host_ipv6_forwarding', 'gateway_ipv6_forwarding'))
|
|
111
|
+
or row['ipv6_external_connectivity'] != 'unverified'):
|
|
112
|
+
raise NetworkFailure('remote_invalid')
|
|
96
113
|
|
|
97
114
|
|
|
98
115
|
def host_records(result, provider):
|
|
@@ -146,6 +163,6 @@ def validate(result, app, service, expected, network, pool, vpn_service, host, p
|
|
|
146
163
|
or not re.fullmatch(r'\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ', result['observed_at'])):
|
|
147
164
|
raise NetworkFailure('remote_invalid')
|
|
148
165
|
bridge_record(result['bridge'], app, network)
|
|
149
|
-
observation_records(result)
|
|
166
|
+
observation_records(result, ipv6=':' in pool)
|
|
150
167
|
host_records(result, provider)
|
|
151
168
|
return result
|
|
@@ -26,23 +26,31 @@ def application(app, service, expected):
|
|
|
26
26
|
return rows
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def snapshot(app, service, expected):
|
|
29
|
+
def snapshot(app, service, expected, ipv6=False):
|
|
30
30
|
return {'application': application(app, service, expected), 'networks': network_inventory.networks(),
|
|
31
|
-
'host': network_inventory.host_networks(), 'bindings': network_inventory.port_bindings(),
|
|
31
|
+
'host': network_inventory.host_networks(include_ipv6=ipv6), 'bindings': network_inventory.port_bindings(),
|
|
32
32
|
'listeners': network_host.listeners()}
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def collect(app, service, expected, network, pool, vpn_service, host, provider):
|
|
36
36
|
scope(app, service, expected, network, pool, vpn_service, host, provider)
|
|
37
37
|
network_host.local_docker()
|
|
38
|
-
|
|
38
|
+
ipv6 = ':' in pool
|
|
39
|
+
original = snapshot(app, service, expected, ipv6=ipv6)
|
|
39
40
|
bridge = network_inventory.selected(original['networks'], app, network)
|
|
40
41
|
host_result = network_metadata.observe(host, original['host'], provider)
|
|
41
42
|
firewall, masquerade, netfilter = network_host.firewall(bridge)
|
|
42
43
|
namespace = network_host.namespace(original['application'], app, vpn_service, bridge)
|
|
43
44
|
namespace.update(netfilter_nat_read=netfilter, selected_bridge_masquerade=masquerade)
|
|
44
|
-
|
|
45
|
+
safe = namespace['vpn_settings'] == 'isolated_net_admin'
|
|
46
|
+
routes = network_host.ipv6_routes(original['application'], app, vpn_service, bridge,
|
|
47
|
+
namespace_safe=safe) if ipv6 else None
|
|
48
|
+
if (snapshot(app, service, expected, ipv6=ipv6) != original
|
|
49
|
+
or ipv6 and network_host.ipv6_routes(original['application'], app, vpn_service, bridge,
|
|
50
|
+
namespace_safe=safe) != routes):
|
|
45
51
|
raise NetworkFailure('network_changed')
|
|
52
|
+
if routes is not None:
|
|
53
|
+
namespace.update(routes)
|
|
46
54
|
result = dict(envelope(app, service, expected, network, pool, vpn_service, host, provider),
|
|
47
55
|
observed_build_sha=expected, bridge=bridge, host=host_result, firewall=firewall, namespace=namespace,
|
|
48
56
|
overlap=network_inventory.overlap(original['networks'], bridge, original['host'], pool),
|
package/docs/BACKEND_SSH_GATE.md
CHANGED
|
@@ -107,7 +107,7 @@ shared-database substitution.
|
|
|
107
107
|
`operation: network-diagnostics` is a read-only operation. The network allocator remains retired.
|
|
108
108
|
Select `diagnostic-service` (the existing API), `diagnostic-network` (the existing Compose bridge key,
|
|
109
109
|
default `default`), `diagnostic-vpn-service` (default `vpn`), `expected-build-sha`, and `diagnostic-pool`:
|
|
110
|
-
a strict RFC1918 IPv4 `/24` proposed only for the VPN container namespace. It creates no
|
|
110
|
+
a strict RFC1918 IPv4 `/24` or IPv6 ULA `/64` proposed only for the VPN container namespace. It creates no reservation,
|
|
111
111
|
network, route, firewall rule, container, certificate or service. Do not use this pool as Compose IPAM.
|
|
112
112
|
The generated workflow transports `diagnostic-build-sha` separately so a workflow-only commit can inspect
|
|
113
113
|
the API build actually deployed, rather than claiming its own checkout SHA is running.
|
|
@@ -123,7 +123,7 @@ released `network_readback.py`. It accepts no uploaded Python or app-mirror help
|
|
|
123
123
|
alone cannot install this host operation: install the integrity-verified published runtime first. A missing
|
|
124
124
|
host reader returns `network_reader_unavailable` with no host reader version; it is not a network verdict.
|
|
125
125
|
|
|
126
|
-
The closed `gowalk-cicd/backend-network-diagnostics.v3` result is retained as
|
|
126
|
+
The closed `gowalk-cicd/backend-network-diagnostics.v3` IPv4 result, or `v4` for IPv6, is retained as
|
|
127
127
|
`backend-network-diagnostics-<job>-<attempt>/result.json`. `ok: true` means the bounded read completed;
|
|
128
128
|
it never means a VPN is provisioned, traffic is reachable, a pool is globally unused or a tunnel works.
|
|
129
129
|
The reader binds the existing API's `BUILD_SHA`, checks its exact project/service/directory labels, and
|
|
@@ -150,12 +150,17 @@ for client compatibility, so prove adoption with the install receipt and the for
|
|
|
150
150
|
only NET_ADMIN plus optional NET_BIND_SERVICE/CHOWN/SETUID/SETGID/SETPCAP for bootstrap and privilege dropping,
|
|
151
151
|
and namespace-only forwarding/redirect settings. This observes grants, not that a daemon actually dropped privileges.
|
|
152
152
|
An absent VPN reports `absent`; these observations do not test creating a namespace or applying capabilities.
|
|
153
|
+
- An IPv6 request additionally compares the proposed ULA against host IPv6 routes, addresses and Docker subnets.
|
|
154
|
+
It reports host and owned-gateway IPv6 default-route and forwarding observations independently; missing tools or
|
|
155
|
+
inaccessible namespaces remain `unverified`, and an absent gateway reports `not_running`. A default route is not
|
|
156
|
+
a usable Internet path: `ipv6_external_connectivity` stays `unverified` until an independent traffic exercise.
|
|
153
157
|
- `firewall` reports observed INPUT/FORWARD policies and explicit UDP ACCEPT rules. Effective ingress
|
|
154
158
|
remains `unverified`: chained rules, nftables and upstream/provider firewalls need actual external traffic proof.
|
|
155
159
|
|
|
156
160
|
Foreign network ownership/configuration, process identities, raw firewall rules, environment values and
|
|
157
161
|
private bindings never leave the host. Foreign subnet data is used privately only to calculate overlap flags.
|
|
158
|
-
The reader
|
|
162
|
+
The IPv6 reader enters only the verified owned container's Docker network namespace using host `ip` and `cat`; it
|
|
163
|
+
executes no container code, reads no logs and contacts no provider management API.
|
|
159
164
|
No retry, downgrade, unrestricted SSH or historical inventory can replace a refused current read.
|
|
160
165
|
Preserve bridge and database identities, assess the observations within their stated scope, then separately
|
|
161
166
|
verify the actual namespaced gateway configuration, IKEv2 negotiation and encrypted forwarded traffic.
|
package/package.json
CHANGED