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 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. The allocator stays retired; a completed read is never
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 container namespace; never a host allocation.
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.IPv4Network(pool)
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.IPv4Network(pool, strict=True)
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
- if (candidate.prefixlen != 24 or not any(candidate.subnet_of(ipaddress.IPv4Network(x)) for x in private)
31
- or provider not in ('none', 'digitalocean')):
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
- return {'schema': SCHEMA, 'reader_version': reader_version, 'ok': not failure, 'failure': failure,
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
- closed(result['namespace'], ('docker_linux_rootful', 'host_ipv4_forwarding', 'xfrm_read',
87
- 'netfilter_nat_read', 'selected_bridge_masquerade', 'vpn_settings'))
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
- original = snapshot(app, service, expected)
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
- if snapshot(app, service, expected) != original:
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),
@@ -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 address reservation,
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 executes no container code, reads no logs and contacts no provider management API.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.207",
3
+ "version": "1.0.208",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,7 @@ on:
37
37
  required: false
38
38
  diagnostic-pool:
39
39
  type: string
40
- description: Proposed namespace-only VPN private IPv4 /24
40
+ description: Proposed namespace-only VPN private IPv4 /24 or IPv6 ULA /64
41
41
  required: false
42
42
  diagnostic-network:
43
43
  type: string