gowalk-cicd 1.0.154 → 1.0.155

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.
@@ -1 +1 @@
1
- 1.0.154
1
+ 1.0.155
@@ -1 +1 @@
1
- 1.0.154
1
+ 1.0.155
@@ -1 +1 @@
1
- 1.0.154
1
+ 1.0.155
@@ -18,6 +18,10 @@ inputs:
18
18
  description: UTC YYYY-MM-DDTHH:MM:SSZ within the previous 24 hours; required for diagnostics.
19
19
  required: false
20
20
  default: ""
21
+ diagnostic-provider:
22
+ description: Optional fixed metadata source for network-diagnostics (none or digitalocean).
23
+ required: false
24
+ default: none
21
25
  host:
22
26
  description: SSH host of the deploy server.
23
27
  required: false
@@ -264,11 +268,13 @@ runs:
264
268
  DIAGNOSTIC_APP: ${{ inputs.app-name }}
265
269
  DIAGNOSTIC_HOST: ${{ inputs.host }}
266
270
  DIAGNOSTIC_USER: ${{ inputs.ssh-user }}
271
+ DIAGNOSTIC_PROVIDER: ${{ inputs.diagnostic-provider }}
267
272
  run: |
268
273
  RESULT_DIR="$(mktemp -d "$RUNNER_TEMP/backend-network-diagnostics.XXXXXX")"
269
274
  echo "result=$RESULT_DIR/result.json" >> "$GITHUB_OUTPUT"
270
275
  python3 "${{ github.action_path }}/network_diagnostics.py" \
271
276
  --app "$DIAGNOSTIC_APP" --host "$DIAGNOSTIC_HOST" --user "$DIAGNOSTIC_USER" \
277
+ --provider "$DIAGNOSTIC_PROVIDER" \
272
278
  --output "$RESULT_DIR/result.json"
273
279
 
274
280
  - name: Retain backend network diagnostics
@@ -9,13 +9,16 @@ import shlex
9
9
 
10
10
  from identity_diagnostics import ReaderFailure, bounded, retain_result
11
11
  from network_pools import FAILURES as INVENTORY_FAILURES
12
+ from network_selected_receipt import validate_selected
13
+ from network_provider import validate_provider
12
14
 
13
- SCHEMA = 'gowalk-cicd/backend-network-diagnostics.v1'
14
- MODULES = ('identity_diagnostics', 'network_runtime', 'network_pools', 'network_inventory')
15
+ SCHEMA = 'gowalk-cicd/backend-network-diagnostics.v2'
16
+ MODULES = ('identity_diagnostics', 'network_runtime', 'network_pools', 'network_inventory',
17
+ 'network_readback', 'network_selected', 'network_provider')
15
18
  FAILURES = INVENTORY_FAILURES | frozenset({'network_inventory_unverified', 'network_command_unavailable', ''})
16
19
 
17
20
 
18
- def remote_source(app):
21
+ def remote_source(app, host='', provider='none'):
19
22
  """Send only package-owned Python sources; no environment, source checkout or private inputs."""
20
23
  sources = {name: Path(__file__).with_name(name + '.py').read_text() for name in MODULES}
21
24
  program = '''import json, sys, types
@@ -26,6 +29,8 @@ for name, source in sources.items():
26
29
  exec(compile(source, name + '.py', 'exec'), module.__dict__)
27
30
  from network_inventory import collect, InventoryFailure
28
31
  from network_runtime import run, NetworkFailure
32
+ from network_selected import selected_validation
33
+ from network_provider import observe
29
34
  result = {'schema': SCHEMA, 'ok': False, 'project': APP, 'failure': ''}
30
35
  try:
31
36
  inventory = collect(run, require_pools=False)
@@ -36,19 +41,24 @@ try:
36
41
  'pool_failure', 'pools', 'observed_at')})
37
42
  result.update(ok=True, complete=True, network_count=len(inventory['networks']),
38
43
  excluded_subnets=inventory['excluded'],
44
+ host_identity=observe(inventory, HOST) if PROVIDER == 'digitalocean' else
45
+ {'provider': 'none', 'status': 'not_requested'},
39
46
  networks=[{'id': n['Id'], 'name': n['Name'], 'driver': n['Driver'],
40
47
  'key': n['Labels'].get('com.docker.compose.network', ''),
41
48
  'subnets': [p['Subnet'] for p in n['IPAM']['Config']],
42
- 'endpoints': len(n['Containers'])} for n in own])
49
+ 'endpoints': len(n['Containers']),
50
+ 'validation': selected_validation(inventory, n, APP)} for n in own])
43
51
  except (InventoryFailure, NetworkFailure) as error:
44
- result['failure'] = error.code
52
+ result = {'schema': SCHEMA, 'ok': False, 'project': APP, 'failure': error.code}
45
53
  result.update(getattr(error, 'details', {}))
46
54
  except Exception:
47
- result['failure'] = 'network_inventory_unverified'
55
+ result = {'schema': SCHEMA, 'ok': False, 'project': APP, 'failure': 'network_inventory_unverified'}
48
56
  print(json.dumps(result, separators=(',', ':')))
49
57
  sys.exit(0 if result['ok'] else 1)
50
58
  '''
51
- return program.replace('SCHEMA', repr(SCHEMA)).replace('APP', repr(app)).replace('SOURCES', repr(sources)).encode()
59
+ program = program.replace('SCHEMA', repr(SCHEMA)).replace('APP', repr(app))
60
+ return program.replace('HOST', repr(host)).replace('PROVIDER', repr(provider)).replace(
61
+ 'SOURCES', repr(sources)).encode()
52
62
 
53
63
 
54
64
  def decode(status, raw, app):
@@ -57,7 +67,7 @@ def decode(status, raw, app):
57
67
  except (ValueError, TypeError):
58
68
  raise ReaderFailure('remote_invalid') from None
59
69
  allowed = {'schema', 'ok', 'project', 'failure', 'docker_version', 'compose_version', 'python_version',
60
- 'pool_source',
70
+ 'pool_source', 'host_identity',
61
71
  'pool_failure', 'pools', 'observed_at', 'complete', 'network_count', 'excluded_subnets', 'networks'}
62
72
  if (not isinstance(result, dict) or set(result) - allowed or result.get('schema') != SCHEMA
63
73
  or result.get('project') != app or type(result.get('ok')) is not bool
@@ -107,16 +117,19 @@ def validate_inventory(result):
107
117
  if not isinstance(result.get(field), list) or len(result[field]) > limit:
108
118
  raise ReaderFailure('remote_invalid')
109
119
  try:
120
+ if result.get('host_identity') != {'provider': 'none', 'status': 'not_requested'}:
121
+ validate_provider(result.get('host_identity'))
110
122
  for value in result['excluded_subnets']:
111
123
  ipaddress.IPv4Network(value, strict=True)
112
124
  for network in result['networks']:
113
- validate_network(network, result['project'])
125
+ validate_network(network, result)
114
126
  except (ValueError, TypeError, KeyError):
115
127
  raise ReaderFailure('remote_invalid') from None
116
128
 
117
129
 
118
- def validate_network(network, project):
119
- if (not isinstance(network, dict) or set(network) != {'id', 'name', 'driver', 'key', 'subnets', 'endpoints'}
130
+ def validate_network(network, inventory):
131
+ fields = {'id', 'name', 'driver', 'key', 'subnets', 'endpoints', 'validation'}
132
+ if (not isinstance(network, dict) or set(network) != fields
120
133
  or not isinstance(network['id'], str) or not re.fullmatch('[0-9a-f]{64}', network['id'])
121
134
  or not isinstance(network['name'], str) or not re.fullmatch('[A-Za-z0-9_.-]{1,256}', network['name'])
122
135
  or not isinstance(network['key'], str) or not re.fullmatch('[A-Za-z0-9_.-]{0,128}', network['key'])
@@ -125,19 +138,22 @@ def validate_network(network, project):
125
138
  or not isinstance(network['subnets'], list) or len(network['subnets']) > 64):
126
139
  raise ValueError()
127
140
  for value in network['subnets']:
141
+ if not isinstance(value, str):
142
+ raise ValueError()
128
143
  ipaddress.ip_network(value, strict=True)
144
+ validate_selected(network['validation'], network, inventory)
129
145
 
130
146
 
131
- def diagnose(app, host, user):
147
+ def diagnose(app, host, user, provider='none'):
132
148
  if (not re.fullmatch(r'[a-z0-9][a-z0-9-]{0,79}', app)
133
149
  or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.-]{0,252}', host)
134
- or not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', user)):
150
+ or not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', user) or provider not in ('none', 'digitalocean')):
135
151
  raise ReaderFailure('invalid_destination')
136
152
  remote = shlex.join(['python3', '-'])
137
153
  argv = ['ssh', '-i', str(Path.home() / '.ssh/backend_deploy'), '-o', 'IdentitiesOnly=yes',
138
154
  '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-o', 'ServerAliveInterval=5',
139
155
  '-o', 'ServerAliveCountMax=2', user + '@' + host, remote]
140
- status, raw = bounded(argv, payload=remote_source(app), seconds=60, with_status=True)
156
+ status, raw = bounded(argv, payload=remote_source(app, host, provider), seconds=60, with_status=True)
141
157
  return decode(status, raw, app)
142
158
 
143
159
 
@@ -146,10 +162,11 @@ def main():
146
162
  parser.add_argument('--app', required=True)
147
163
  parser.add_argument('--host', required=True)
148
164
  parser.add_argument('--user', default='root')
165
+ parser.add_argument('--provider', choices=('none', 'digitalocean'), default='none')
149
166
  parser.add_argument('--output', required=True)
150
167
  args = parser.parse_args()
151
168
  try:
152
- result = diagnose(args.app, args.host, args.user)
169
+ result = diagnose(args.app, args.host, args.user, args.provider)
153
170
  except Exception as error:
154
171
  code = error.code if isinstance(error, ReaderFailure) else 'network_diagnostic_unverified'
155
172
  result = {'schema': SCHEMA, 'ok': False, 'project': args.app, 'failure': code}
@@ -0,0 +1,213 @@
1
+ """Optional fixed DigitalOcean metadata readback; identity evidence never delegates Docker address space."""
2
+ import ipaddress
3
+ import re
4
+ import socket
5
+ import time
6
+
7
+ ORIGIN = ('169.254.169.254', 80)
8
+ PATHS = {
9
+ 'droplet_id': '/metadata/v1/id',
10
+ '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
+ 'private_gateway': '/metadata/v1/interfaces/private/0/ipv4/gateway',
15
+ }
16
+ IDENTITY = ('droplet_id', 'region', 'public_ipv4', 'private_ipv4', 'private_subnet', 'private_gateway')
17
+ PRIVATE = tuple(ipaddress.IPv4Network(value) for value in ('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'))
18
+ REASONS = {
19
+ 'verified': {''},
20
+ 'mismatch': {'public_address_mismatch', 'public_address_unobserved', 'private_address_mismatch'},
21
+ 'unavailable': {'metadata_missing', 'metadata_unavailable', 'metadata_timeout'},
22
+ 'unverified': {'expected_host_unverified', 'host_inventory_unverified',
23
+ 'metadata_invalid', 'metadata_response_limit'},
24
+ }
25
+
26
+
27
+ class MetadataFailure(ValueError):
28
+ pass
29
+
30
+
31
+ def _timeout(deadline):
32
+ remaining = deadline - time.monotonic()
33
+ if remaining <= 0:
34
+ raise MetadataFailure('metadata_timeout')
35
+ return remaining
36
+
37
+
38
+ def _headers(connection, deadline):
39
+ raw = bytearray()
40
+ while not raw.endswith(b'\r\n\r\n'):
41
+ if len(raw) >= 8192:
42
+ raise MetadataFailure('metadata_response_limit')
43
+ connection.settimeout(_timeout(deadline))
44
+ value = connection.recv(1)
45
+ if not value:
46
+ raise MetadataFailure('metadata_invalid')
47
+ raw.extend(value)
48
+ lines = bytes(raw[:-4]).split(b'\r\n')
49
+ if len(lines) > 17:
50
+ raise MetadataFailure('metadata_response_limit')
51
+ match = re.fullmatch(rb'HTTP/1\.[01] ([1-5][0-9]{2})(?: [\x20-\x7e]*)?', lines[0])
52
+ if not match:
53
+ raise MetadataFailure('metadata_invalid')
54
+ if match[1] != b'200':
55
+ raise MetadataFailure('metadata_missing' if match[1] == b'404' else 'metadata_unavailable')
56
+ headers = {}
57
+ for line in lines[1:]:
58
+ match = re.fullmatch(rb"([!#$%&'*+.^_`|~0-9A-Za-z-]+):[ \t]*([\x20-\x7e\t]*)", line)
59
+ if not match or match[1].lower() in headers:
60
+ raise MetadataFailure('metadata_invalid')
61
+ headers[match[1].lower()] = match[2].strip()
62
+ if b'transfer-encoding' in headers or headers.get(b'content-encoding', b'identity') != b'identity':
63
+ raise MetadataFailure('metadata_invalid')
64
+ length = headers.get(b'content-length')
65
+ if length is not None and not re.fullmatch(rb'[0-9]{1,3}', length):
66
+ raise MetadataFailure('metadata_invalid')
67
+ if length is not None and int(length) > 128:
68
+ raise MetadataFailure('metadata_response_limit')
69
+ return None if length is None else int(length)
70
+
71
+
72
+ def _fetch(path, deadline):
73
+ """One HTTP/1.0 request, no proxy, DNS, redirect, authentication, discovery or retry."""
74
+ if path not in PATHS.values():
75
+ raise MetadataFailure('metadata_invalid')
76
+ deadline = min(deadline, time.monotonic() + 2)
77
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as connection:
78
+ connection.settimeout(_timeout(deadline))
79
+ connection.connect(ORIGIN)
80
+ connection.settimeout(_timeout(deadline))
81
+ request = 'GET ' + path + ' HTTP/1.0\r\nHost: 169.254.169.254\r\nConnection: close\r\n\r\n'
82
+ connection.sendall(request.encode('ascii'))
83
+ length = _headers(connection, deadline)
84
+ body = bytearray()
85
+ limit = 129 if length is None else length
86
+ while len(body) < limit:
87
+ connection.settimeout(_timeout(deadline))
88
+ value = connection.recv(limit - len(body))
89
+ if not value:
90
+ break
91
+ body.extend(value)
92
+ _timeout(deadline)
93
+ if len(body) > 128:
94
+ raise MetadataFailure('metadata_response_limit')
95
+ if length is not None and len(body) != length:
96
+ raise MetadataFailure('metadata_invalid')
97
+ return bytes(body)
98
+
99
+
100
+ def _address(value):
101
+ if not isinstance(value, str):
102
+ raise ValueError()
103
+ address = ipaddress.IPv4Address(value)
104
+ if str(address) != value:
105
+ raise ValueError()
106
+ return address
107
+
108
+
109
+ def _identity(values):
110
+ if (not re.fullmatch(r'[1-9][0-9]{0,19}', values['droplet_id'])
111
+ or not re.fullmatch(r'[a-z][a-z0-9-]{1,31}', values['region'])):
112
+ raise ValueError()
113
+ _address(values['public_ipv4'])
114
+ private, gateway = _address(values['private_ipv4']), _address(values['private_gateway'])
115
+ subnet = ipaddress.IPv4Network(values['private_subnet'], strict=True)
116
+ if (str(subnet) != values['private_subnet'] or subnet.prefixlen > 30
117
+ or not any(subnet.subnet_of(space) for space in PRIVATE)
118
+ or any(address not in subnet or address in (subnet.network_address, subnet.broadcast_address)
119
+ for address in (private, gateway)) or private == gateway):
120
+ raise ValueError()
121
+
122
+
123
+ def _read_values(fetch, deadline):
124
+ values = {}
125
+ for field, path in PATHS.items():
126
+ leaf_deadline = min(deadline, time.monotonic() + 2)
127
+ _timeout(leaf_deadline)
128
+ raw = fetch(path, leaf_deadline)
129
+ _timeout(leaf_deadline)
130
+ if not isinstance(raw, bytes):
131
+ raise MetadataFailure('metadata_invalid')
132
+ if len(raw) > 128:
133
+ raise MetadataFailure('metadata_response_limit')
134
+ values[field] = raw.decode('ascii').strip(' \t\r\n')
135
+ mask = values.pop('netmask')
136
+ mask_network = ipaddress.IPv4Network('0.0.0.0/' + mask)
137
+ if str(mask_network.netmask) != mask:
138
+ raise ValueError()
139
+ values['private_subnet'] = str(ipaddress.IPv4Network(values['private_ipv4'] + '/' + mask, strict=False))
140
+ _identity(values)
141
+ return values
142
+
143
+
144
+ def _binding(inventory, values, expected_host):
145
+ records = inventory['host_records']
146
+ bridges = {'docker0'}
147
+ for network in inventory['networks']:
148
+ if network['Driver'] == 'bridge':
149
+ bridges.add('br-' + network['Id'][:12])
150
+ bridges.add((network.get('Options') or {}).get('com.docker.network.bridge.name', ''))
151
+ facts = {}
152
+ for row in records:
153
+ facts.setdefault((row['kind'], row['subnet']), set()).add(row['device'])
154
+ if values['public_ipv4'] != expected_host:
155
+ return 'public_address_mismatch', 'unverified'
156
+ public = facts.get(('address_local', values['public_ipv4'] + '/32'), set())
157
+ if len(public) != 1 or public & bridges or any(not device or device.startswith('br-') for device in public):
158
+ return 'public_address_unobserved', 'unverified'
159
+ local = facts.get(('address_local', values['private_ipv4'] + '/32'), set())
160
+ network = facts.get(('address_network', values['private_subnet']), set())
161
+ if (len(local) != 1 or local != network or local & bridges
162
+ or any(not device or device.startswith('br-') for device in local)):
163
+ return 'private_address_mismatch', 'unverified'
164
+ gateways = facts.get(('route_gateway', values['private_gateway'] + '/32'), set())
165
+ return '', 'observed' if local & gateways else 'not_observed'
166
+
167
+
168
+ def observe(inventory, expected_host, fetch=None):
169
+ """Called only by explicit provider diagnostics; fetch(path, deadline) permits offline fixtures."""
170
+ result = dict.fromkeys(IDENTITY, '')
171
+ result.update(provider='digitalocean', status='unverified', reason='expected_host_unverified',
172
+ private_gateway_route='unverified')
173
+ try:
174
+ _address(expected_host)
175
+ except (ValueError, TypeError):
176
+ return result
177
+ if (not isinstance(inventory, dict) or inventory.get('complete') is not True
178
+ or not isinstance(inventory.get('host_records'), list) or not isinstance(inventory.get('networks'), list)):
179
+ return dict(result, reason='host_inventory_unverified')
180
+ try:
181
+ values = _read_values(fetch or _fetch, time.monotonic() + 10)
182
+ except Exception as error:
183
+ reason = str(error) if isinstance(error, MetadataFailure) else 'metadata_invalid'
184
+ if isinstance(error, (socket.timeout, TimeoutError)):
185
+ reason = 'metadata_timeout'
186
+ elif isinstance(error, OSError):
187
+ reason = 'metadata_unavailable'
188
+ if reason not in REASONS['unavailable'] | REASONS['unverified']:
189
+ reason = 'metadata_invalid'
190
+ return dict(result, reason=reason, status='unavailable' if reason in REASONS['unavailable'] else 'unverified')
191
+ try:
192
+ reason, route = _binding(inventory, values, expected_host)
193
+ except Exception:
194
+ return dict(result, reason='host_inventory_unverified')
195
+ result.update(values, status='mismatch' if reason else 'verified', reason=reason, private_gateway_route=route)
196
+ return result
197
+
198
+
199
+ def validate_provider(receipt):
200
+ fields = set(IDENTITY) | {'provider', 'status', 'reason', 'private_gateway_route'}
201
+ if (not isinstance(receipt, dict) or set(receipt) != fields
202
+ or any(not isinstance(value, str) for value in receipt.values())
203
+ or receipt['provider'] != 'digitalocean' or receipt['status'] not in REASONS
204
+ or receipt['reason'] not in REASONS[receipt['status']]):
205
+ raise ValueError()
206
+ if receipt['status'] in ('unavailable', 'unverified'):
207
+ if any(receipt[field] for field in IDENTITY) or receipt['private_gateway_route'] != 'unverified':
208
+ raise ValueError()
209
+ return
210
+ _identity(receipt)
211
+ routes = ('observed', 'not_observed') if receipt['status'] == 'verified' else ('unverified',)
212
+ if receipt['private_gateway_route'] not in routes:
213
+ raise ValueError()
@@ -0,0 +1,30 @@
1
+ """Safe in-memory metadata and HTTP socket fixtures; no external reads."""
2
+ import copy
3
+ import io
4
+ from unittest.mock import MagicMock, Mock
5
+
6
+ from network_provider import PATHS, observe, validate_provider
7
+
8
+ PUBLIC = '138.197.36.107'
9
+ VALUES = {'droplet_id': b'123456789', 'region': b'nyc3\n', 'public_ipv4': PUBLIC.encode(),
10
+ 'private_ipv4': b'10.116.0.2', 'netmask': b'255.255.240.0', 'private_gateway': b'10.116.0.1'}
11
+ INVENTORY = {'complete': True, 'networks': [], 'host_records': [
12
+ {'subnet': PUBLIC + '/32', 'kind': 'address_local', 'device': 'eth0'},
13
+ {'subnet': '10.116.0.2/32', 'kind': 'address_local', 'device': 'eth1'},
14
+ {'subnet': '10.116.0.0/20', 'kind': 'address_network', 'device': 'eth1'},
15
+ ]}
16
+
17
+
18
+ def fixture(values=None, inventory=None, expected=PUBLIC):
19
+ answers = dict(VALUES, **(values or {}))
20
+ fetch = Mock(side_effect=lambda path, deadline: answers[next(key for key in PATHS if PATHS[key] == path)])
21
+ result = observe(copy.deepcopy(INVENTORY) if inventory is None else inventory, expected, fetch)
22
+ validate_provider(result)
23
+ return result, fetch
24
+
25
+
26
+ def connection(wire):
27
+ fake = MagicMock()
28
+ fake.__enter__.return_value = fake
29
+ fake.recv.side_effect = io.BytesIO(wire).read
30
+ return fake
@@ -4,13 +4,10 @@ import ipaddress
4
4
  from network_runtime import NetworkFailure
5
5
 
6
6
 
7
- def verify_host_overlap(inventory, created, subnet):
7
+ def expected_host_records(subnet, gateway):
8
+ """Exact kernel/address records of an ordinary IPv4 bridge, without a device exemption."""
8
9
  selected = ipaddress.IPv4Network(subnet)
9
- records = inventory.get('host_records', [])
10
- if set(inventory.get('host_excluded', [])) - {row['subnet'] for row in records}:
11
- raise NetworkFailure('created_network_overlap')
12
- pool = created['IPAM']['Config'][0]
13
- gateway = str(ipaddress.IPv4Network(pool['Gateway']))
10
+ gateway = str(ipaddress.IPv4Network(gateway))
14
11
  broadcast = str(selected.broadcast_address) + '/32'
15
12
  first = str(selected.network_address) + '/32'
16
13
  expected = {'route_connected': {subnet}, 'route_local': {gateway},
@@ -18,6 +15,15 @@ def verify_host_overlap(inventory, created, subnet):
18
15
  'address_local': {gateway}, 'address_broadcast': {broadcast}}
19
16
  for kind in ('route_connected_source', 'route_local_source', 'route_broadcast_source'):
20
17
  expected[kind] = {gateway}
18
+ return expected
19
+
20
+
21
+ def verify_host_overlap(inventory, created, subnet):
22
+ selected = ipaddress.IPv4Network(subnet)
23
+ records = inventory.get('host_records', [])
24
+ if set(inventory.get('host_excluded', [])) - {row['subnet'] for row in records}:
25
+ raise NetworkFailure('created_network_overlap')
26
+ expected = expected_host_records(subnet, created['IPAM']['Config'][0]['Gateway'])
21
27
  bridge = 'br-' + created['Id'][:12]
22
28
  for row in records:
23
29
  if selected.overlaps(ipaddress.IPv4Network(row['subnet'])):
@@ -0,0 +1,96 @@
1
+ """Project-owned existing bridge overlap evidence; never allocation or address-policy authorization."""
2
+ import ipaddress
3
+ import re
4
+
5
+ from network_pools import PRIVATE
6
+ from network_readback import expected_host_records
7
+
8
+ LIMIT = 256
9
+ REASONS = frozenset({'', 'selected_ownership_unverified', 'selected_bridge_unsupported',
10
+ 'host_records_incomplete', 'selected_bridge_records_missing', 'overlap_evidence_limit'})
11
+ KINDS = frozenset({'route_other', 'route_connected', 'route_local', 'route_broadcast',
12
+ 'route_other_source', 'route_connected_source', 'route_local_source', 'route_broadcast_source',
13
+ 'route_gateway', 'route_src', 'address_network', 'address_local', 'address_peer',
14
+ 'address_broadcast', 'resolver'})
15
+
16
+
17
+ def profile(network, inventory, project):
18
+ """Bind only an unambiguous ordinary Compose name/project/key and its actual IPAM gateway."""
19
+ labels = network['Labels']
20
+ key = labels.get('com.docker.compose.network', '')
21
+ if (labels.get('com.docker.compose.project') != project or not re.fullmatch(r'[A-Za-z0-9_.-]{1,128}', key)
22
+ or network['Name'] != project + '_' + key
23
+ or sum(n['Name'] == network['Name'] for n in inventory['networks']) != 1
24
+ or sum(n['Id'] == network['Id'] for n in inventory['networks']) != 1):
25
+ return None, 'selected_ownership_unverified'
26
+ ipam = network['IPAM']
27
+ if (network['Driver'] != 'bridge' or network['Scope'] != 'local' or network['EnableIPv6']
28
+ or network['Options'] or ipam['Driver'] != 'default' or ipam['Options']
29
+ or len(ipam['Config']) != 1):
30
+ return None, 'selected_bridge_unsupported'
31
+ pool = ipam['Config'][0]
32
+ try:
33
+ subnet = ipaddress.IPv4Network(pool['Subnet'], strict=True)
34
+ gateway = ipaddress.IPv4Address(pool['Gateway'])
35
+ if (set(pool) - {'Subnet', 'Gateway', 'IPRange', 'AuxiliaryAddresses'}
36
+ or pool.get('IPRange') not in (None, '') or pool.get('AuxiliaryAddresses') not in (None, {})
37
+ or subnet.prefixlen > 30
38
+ or not any(subnet.subnet_of(space) for space in PRIVATE) or gateway not in subnet
39
+ or gateway in (subnet.network_address, subnet.broadcast_address)):
40
+ raise ValueError()
41
+ except (KeyError, ValueError, TypeError):
42
+ return None, 'selected_bridge_unsupported'
43
+ return {'subnet': subnet, 'gateway': str(gateway), 'bridge': 'br-' + network['Id'][:12]}, ''
44
+
45
+
46
+ def pool_membership(inventory, subnet):
47
+ if inventory['pool_failure'] or inventory['pool_source'] not in ('engine_info', 'versioned_moby_builtin'):
48
+ return 'unverified'
49
+ return ('inside_verified_pools' if any(subnet.subnet_of(ipaddress.IPv4Network(p['base']))
50
+ for p in inventory['pools']) else 'outside_verified_pools')
51
+
52
+
53
+ def host_overlaps(records, selected):
54
+ expected = expected_host_records(str(selected['subnet']), selected['gateway'])
55
+ own, conflicts = set(), set()
56
+ for row in records:
57
+ if not selected['subnet'].overlaps(ipaddress.IPv4Network(row['subnet'])):
58
+ continue
59
+ if row['device'] == selected['bridge'] and row['subnet'] in expected.get(row['kind'], set()):
60
+ own.add((row['subnet'], row['kind']))
61
+ else:
62
+ relation = 'selected_bridge' if row['device'] == selected['bridge'] else 'foreign_device'
63
+ conflicts.add((row['subnet'], row['kind'], relation if row['device'] else 'unattributed'))
64
+ return own, [{'subnet': subnet, 'kind': kind, 'device_relation': relation}
65
+ for subnet, kind, relation in sorted(conflicts)]
66
+
67
+
68
+ def selected_validation(inventory, network, project):
69
+ """A clear snapshot does not establish a reservation outside the verified daemon pool bases."""
70
+ selected, reason = profile(network, inventory, project)
71
+ result = {'ownership': 'unverified' if reason == 'selected_ownership_unverified' else 'matched',
72
+ 'overlap': 'unverified', 'reason': reason, 'pool_membership': 'unverified',
73
+ 'bridge': '', 'gateway': '', 'self_record_count': 0, 'docker_conflicts': [], 'host_conflicts': []}
74
+ if selected is None:
75
+ return result
76
+ subnet = selected['subnet']
77
+ result.update(bridge=selected['bridge'], gateway=selected['gateway'],
78
+ pool_membership=pool_membership(inventory, subnet))
79
+ docker = sorted({p['Subnet'] for n in inventory['networks'] if n['Id'] != network['Id']
80
+ for p in n['IPAM']['Config'] if ipaddress.ip_network(p['Subnet']).version == 4
81
+ and subnet.overlaps(ipaddress.IPv4Network(p['Subnet']))})
82
+ records = inventory['host_records']
83
+ own, host = host_overlaps(records, selected)
84
+ required = {(str(subnet), 'route_connected'), (str(subnet), 'address_network'),
85
+ (selected['gateway'] + '/32', 'route_local'), (selected['gateway'] + '/32', 'address_local')}
86
+ if (inventory.get('complete') is not True
87
+ or set(inventory['host_excluded']) != {row['subnet'] for row in records}):
88
+ reason = 'host_records_incomplete'
89
+ elif not required.issubset(own):
90
+ reason = 'selected_bridge_records_missing'
91
+ elif len(docker) > LIMIT or len(host) > LIMIT:
92
+ reason = 'overlap_evidence_limit'
93
+ result.update(reason=reason, self_record_count=len(own), docker_conflicts=docker[:LIMIT],
94
+ host_conflicts=host[:LIMIT], overlap='conflict' if docker or host else
95
+ ('unverified' if reason else 'clear'))
96
+ return result
@@ -0,0 +1,60 @@
1
+ """Closed public validation for selected-network overlap evidence, with no raw host records."""
2
+ import ipaddress
3
+
4
+ from network_selected import KINDS, LIMIT, REASONS, pool_membership
5
+ from network_pools import PRIVATE
6
+
7
+
8
+ def validate_selected(result, network, inventory):
9
+ fields = {'ownership', 'overlap', 'reason', 'pool_membership', 'bridge', 'gateway',
10
+ 'self_record_count', 'docker_conflicts', 'host_conflicts'}
11
+ if (not isinstance(result, dict) or set(result) != fields or result['reason'] not in REASONS
12
+ or result['ownership'] not in ('matched', 'unverified')
13
+ or result['overlap'] not in ('clear', 'conflict', 'unverified')
14
+ or result['pool_membership'] not in ('inside_verified_pools', 'outside_verified_pools', 'unverified')
15
+ or type(result['self_record_count']) is not int or not 0 <= result['self_record_count'] <= 16384):
16
+ raise ValueError()
17
+ for field in ('docker_conflicts', 'host_conflicts'):
18
+ if not isinstance(result[field], list) or len(result[field]) > LIMIT:
19
+ raise ValueError()
20
+ if (result['ownership'] == 'matched'
21
+ and network['name'] != inventory['project'] + '_' + network['key']):
22
+ raise ValueError()
23
+ unsupported = result['reason'] in ('selected_ownership_unverified', 'selected_bridge_unsupported')
24
+ if unsupported:
25
+ if (result['overlap'] != 'unverified' or result['bridge'] != '' or result['gateway'] != ''
26
+ or result['self_record_count'] or result['docker_conflicts'] or result['host_conflicts']
27
+ or result['pool_membership'] != 'unverified'):
28
+ raise ValueError()
29
+ if (result['ownership'] == 'unverified') != (result['reason'] == 'selected_ownership_unverified'):
30
+ raise ValueError()
31
+ return
32
+ if (result['ownership'] != 'matched' or network['driver'] != 'bridge'
33
+ or result['bridge'] != 'br-' + network['id'][:12] or not isinstance(result['gateway'], str)
34
+ or len(network['subnets']) != 1):
35
+ raise ValueError()
36
+ subnet = ipaddress.IPv4Network(network['subnets'][0], strict=True)
37
+ gateway = ipaddress.IPv4Address(result['gateway'])
38
+ if (subnet.prefixlen > 30 or not any(subnet.subnet_of(space) for space in PRIVATE)
39
+ or gateway not in subnet or gateway in (subnet.network_address, subnet.broadcast_address)
40
+ or result['pool_membership'] != pool_membership(inventory, subnet)):
41
+ raise ValueError()
42
+ validate_overlaps(result, subnet)
43
+
44
+
45
+ def validate_overlaps(result, subnet):
46
+ for value in result['docker_conflicts']:
47
+ if not isinstance(value, str) or not subnet.overlaps(ipaddress.IPv4Network(value, strict=True)):
48
+ raise ValueError()
49
+ for row in result['host_conflicts']:
50
+ if (not isinstance(row, dict) or set(row) != {'subnet', 'kind', 'device_relation'}
51
+ or not isinstance(row['subnet'], str)
52
+ or row['kind'] not in KINDS
53
+ or row['device_relation'] not in ('selected_bridge', 'foreign_device', 'unattributed')
54
+ or not subnet.overlaps(ipaddress.IPv4Network(row['subnet'], strict=True))):
55
+ raise ValueError()
56
+ conflict = bool(result['docker_conflicts'] or result['host_conflicts'])
57
+ if ((result['overlap'] == 'conflict') != conflict
58
+ or (result['overlap'] == 'clear' and (result['reason'] or result['self_record_count'] < 4))
59
+ or (result['overlap'] == 'unverified' and not result['reason'])):
60
+ raise ValueError()
@@ -7,6 +7,7 @@ from unittest.mock import patch
7
7
  from identity_diagnostics import ReaderFailure
8
8
  from network_diagnostics import SCHEMA, decode, diagnose, remote_source
9
9
  from network_test_fixture import Host
10
+ from test_network_selected import fixture
10
11
 
11
12
 
12
13
  class DiagnosticTests(unittest.TestCase):
@@ -43,6 +44,25 @@ class DiagnosticTests(unittest.TestCase):
43
44
  'observed_at': '2026-09-15T00:00:00+00:00', 'complete': False}
44
45
  self.assertEqual(decode(1, json.dumps(evidence).encode(), 'fixture'), evidence)
45
46
 
47
+ def test_optional_metadata_failure_keeps_independent_selected_network_observation(self):
48
+ inventory, selected = fixture()
49
+ metadata = dict.fromkeys(('droplet_id', 'region', 'public_ipv4', 'private_ipv4',
50
+ 'private_subnet', 'private_gateway'), '')
51
+ metadata.update(provider='digitalocean', status='unavailable', reason='metadata_timeout',
52
+ private_gateway_route='unverified')
53
+ source = remote_source('fixture', '138.197.36.107', 'digitalocean').decode().replace(
54
+ 'inventory = collect(run, require_pools=False)', 'inventory = ' + repr(inventory)).replace(
55
+ "observe(inventory, '138.197.36.107')", repr(metadata))
56
+ result = subprocess.run([sys.executable, '-'], input=source.encode(), stdout=subprocess.PIPE,
57
+ stderr=subprocess.PIPE, timeout=5)
58
+ self.assertEqual(result.returncode, 0, result.stderr.decode())
59
+ evidence = decode(0, result.stdout, 'fixture')
60
+ self.assertEqual(evidence['host_identity'], metadata)
61
+ self.assertEqual(evidence['networks'][0]['validation']['overlap'], 'clear')
62
+ evidence['host_identity']['secret'] = 'PRIVATE_SENTINEL'
63
+ with self.assertRaises(ReaderFailure):
64
+ decode(0, json.dumps(evidence).encode(), 'fixture')
65
+
46
66
  def test_unrecognized_fields_codes_and_transport_results_are_not_published(self):
47
67
  evidence = {'schema': SCHEMA, 'ok': False, 'project': 'fixture', 'failure': 'host_rules_unsupported'}
48
68
  for changed in ({**evidence, 'secret': 'PRIVATE_SENTINEL'}, {**evidence, 'failure': 'PRIVATE_SENTINEL'},
@@ -0,0 +1,162 @@
1
+ """Offline metadata fixtures: no real socket, deployment host, cloud API or credentials."""
2
+ import copy
3
+ import json
4
+ import socket
5
+ import unittest
6
+ from unittest.mock import Mock, patch
7
+
8
+ from network_provider import MetadataFailure, ORIGIN, PATHS, _fetch, observe, validate_provider
9
+ from network_provider_test_fixture import INVENTORY, PUBLIC, VALUES, connection, fixture
10
+
11
+
12
+ class ProviderTests(unittest.TestCase):
13
+ def test_identity_and_private_gateway_route_are_separate(self):
14
+ result, fetch = fixture()
15
+ self.assertEqual(result['status'], 'verified')
16
+ self.assertEqual(result['private_subnet'], '10.116.0.0/20')
17
+ self.assertEqual(result['private_gateway_route'], 'not_observed')
18
+ self.assertEqual([call.args[0] for call in fetch.call_args_list], list(PATHS.values()))
19
+ self.assertEqual(len(fetch.call_args_list), 6)
20
+ inventory = copy.deepcopy(INVENTORY)
21
+ inventory['host_records'].append({'subnet': '10.116.0.1/32', 'kind': 'route_gateway', 'device': 'eth1'})
22
+ self.assertEqual(fixture(inventory=inventory)[0]['private_gateway_route'], 'observed')
23
+ inventory['host_records'][-1]['device'] = 'eth2'
24
+ result = fixture(inventory=inventory)[0]
25
+ self.assertEqual((result['status'], result['private_gateway_route']), ('verified', 'not_observed'))
26
+ self.assertNotIn('policy', json.dumps(result))
27
+
28
+ def test_expected_host_requires_literal_ipv4_and_matching_kernel_address(self):
29
+ for expected in ('host.example', '::1', '138.197.036.107', 123, None):
30
+ result, fetch = fixture(expected=expected)
31
+ self.assertEqual(result['reason'], 'expected_host_unverified')
32
+ fetch.assert_not_called()
33
+ result, _ = fixture(expected='138.197.36.108')
34
+ self.assertEqual((result['status'], result['reason']), ('mismatch', 'public_address_mismatch'))
35
+ self.assertEqual(result['droplet_id'], '123456789')
36
+ inventory = copy.deepcopy(INVENTORY)
37
+ inventory['host_records'].pop(0)
38
+ self.assertEqual(fixture(inventory=inventory)[0]['reason'], 'public_address_unobserved')
39
+ for inventory in ({}, dict(INVENTORY, complete=False)):
40
+ result, fetch = fixture(inventory=inventory)
41
+ self.assertEqual(result['reason'], 'host_inventory_unverified')
42
+ fetch.assert_not_called()
43
+
44
+ def test_private_identity_requires_one_matching_nonbridge_device(self):
45
+ variants = []
46
+ for index, device in ((1, 'eth2'), (2, 'eth2'), (1, ''), (1, 'br-fixture')):
47
+ inventory = copy.deepcopy(INVENTORY)
48
+ inventory['host_records'][index]['device'] = device
49
+ variants.append(inventory)
50
+ inventory = copy.deepcopy(INVENTORY)
51
+ inventory['host_records'].append(dict(inventory['host_records'][1], device='eth2'))
52
+ variants.append(inventory)
53
+ inventory = copy.deepcopy(INVENTORY)
54
+ inventory['networks'] = [{'Driver': 'bridge', 'Id': 'a' * 64,
55
+ 'Options': {'com.docker.network.bridge.name': 'eth1'}}]
56
+ variants.append(inventory)
57
+ inventory = copy.deepcopy(INVENTORY)
58
+ inventory['host_records'][2]['subnet'] = '10.116.0.0/24'
59
+ variants.append(inventory)
60
+ for inventory in variants:
61
+ with self.subTest(inventory=inventory):
62
+ self.assertEqual(fixture(inventory=inventory)[0]['reason'], 'private_address_mismatch')
63
+
64
+ def test_malformed_leaves_are_closed_and_never_retained(self):
65
+ changes = [('droplet_id', b'0'), ('droplet_id', b'123456789012345678901'),
66
+ ('region', b'PRIVATE_SENTINEL'), ('region', b'nyc3\x00'),
67
+ ('public_ipv4', b'138.197.036.107'), ('public_ipv4', b'::1'),
68
+ ('private_ipv4', b'8.8.8.8'), ('private_ipv4', b'10.116.0.0'),
69
+ ('private_gateway', b'10.116.16.1'), ('private_gateway', b'10.116.0.2'),
70
+ ('private_gateway', b'10.116.15.255'), ('netmask', b'255.0.255.0'),
71
+ ('netmask', b'0.0.15.255'), ('netmask', b'20'), ('netmask', b'255.255.255.255'),
72
+ ('droplet_id', b'PRIVATE_SENTINEL' * 10), ('region', b'\xff')]
73
+ for field, value in changes:
74
+ with self.subTest(field=field, value=value):
75
+ result, _ = fixture({field: value})
76
+ self.assertEqual(result['status'], 'unverified')
77
+ self.assertEqual(result['droplet_id'], '')
78
+ self.assertNotIn('PRIVATE_SENTINEL', json.dumps(result))
79
+ for error, reason in ((socket.timeout('PRIVATE_SENTINEL'), 'metadata_timeout'),
80
+ (OSError('PRIVATE_SENTINEL'), 'metadata_unavailable'),
81
+ (MetadataFailure('PRIVATE_SENTINEL'), 'metadata_invalid')):
82
+ result = observe(INVENTORY, PUBLIC, Mock(side_effect=error))
83
+ validate_provider(result)
84
+ self.assertEqual(result['reason'], reason)
85
+ self.assertNotIn('PRIVATE_SENTINEL', json.dumps(result))
86
+
87
+ def test_transport_is_fixed_bounded_and_has_no_redirect_or_proxy_path(self):
88
+ fake = connection(b'HTTP/1.0 200 OK\r\nContent-Length: 3\r\n\r\n123')
89
+ with patch('network_provider.socket.socket', return_value=fake) as factory, \
90
+ patch('network_provider.time.monotonic', return_value=0), \
91
+ patch.dict('os.environ', {'HTTP_PROXY': 'http://PRIVATE_SENTINEL:80'}):
92
+ self.assertEqual(_fetch(PATHS['droplet_id'], 10), b'123')
93
+ factory.assert_called_once_with(socket.AF_INET, socket.SOCK_STREAM)
94
+ fake.connect.assert_called_once_with(ORIGIN)
95
+ fake.sendall.assert_called_once_with(
96
+ b'GET /metadata/v1/id HTTP/1.0\r\nHost: 169.254.169.254\r\nConnection: close\r\n\r\n')
97
+ self.assertTrue(all(0 < call.args[0] <= 2 for call in fake.settimeout.call_args_list))
98
+ self.assertTrue(all(call.args[0] <= 129 for call in fake.recv.call_args_list))
99
+ for path in ('/metadata/v1.json', '/metadata/v1/user-data', '/metadata/v1/auth-token',
100
+ '/metadata/v1/id?x=1', 'http://other/metadata/v1/id'):
101
+ with patch('network_provider.socket.socket') as factory, self.assertRaises(MetadataFailure):
102
+ _fetch(path, 10)
103
+ factory.assert_not_called()
104
+
105
+ def test_http_refusals_headers_and_body_limits_do_not_leak_content(self):
106
+ cases = [(b'HTTP/1.0 404 Missing\r\n\r\nPRIVATE_SENTINEL', 'metadata_missing'),
107
+ (b'HTTP/1.0 302 Found\r\nLocation: http://other/\r\n\r\n', 'metadata_unavailable'),
108
+ (b'HTTP/1.0 200 OK\r\nX: ' + b'a' * 8192 + b'\r\n\r\n', 'metadata_response_limit'),
109
+ (b'HTTP/1.0 200 OK\r\n' + b'X: a\r\n' * 17 + b'\r\n', 'metadata_response_limit'),
110
+ (b'HTTP/1.0 200 OK\r\n\r\n' + b'a' * 129, 'metadata_response_limit'),
111
+ (b'HTTP/1.0 200 OK\r\nContent-Length: 129\r\n\r\n', 'metadata_response_limit'),
112
+ (b'HTTP/1.0 200 OK\r\nContent-Length: 3\r\n\r\n12', 'metadata_invalid'),
113
+ (b'HTTP/1.0 200 OK\r\nContent-Length: 1\r\nContent-Length: 1\r\n\r\n1', 'metadata_invalid'),
114
+ (b'HTTP/1.0 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n', 'metadata_invalid'),
115
+ (b'HTTP/1.0 200 OK\r\nContent-Encoding: gzip\r\n\r\n', 'metadata_invalid')]
116
+ for wire, reason in cases:
117
+ with self.subTest(reason=reason), patch('network_provider.socket.socket', return_value=connection(wire)), \
118
+ patch('network_provider.time.monotonic', return_value=0):
119
+ result = observe(INVENTORY, PUBLIC)
120
+ validate_provider(result)
121
+ self.assertEqual(result['reason'], reason)
122
+ self.assertNotIn('PRIVATE_SENTINEL', json.dumps(result))
123
+
124
+ def test_deadlines_stop_slow_leaves_and_bound_the_whole_sequence(self):
125
+ clock = [0]
126
+ def slow(path, deadline):
127
+ clock[0] += 2.1
128
+ return b'123'
129
+ with patch('network_provider.time.monotonic', side_effect=lambda: clock[0]):
130
+ fetch = Mock(side_effect=slow)
131
+ self.assertEqual(observe(INVENTORY, PUBLIC, fetch)['reason'], 'metadata_timeout')
132
+ self.assertEqual(fetch.call_count, 1)
133
+ clock[0] = 0
134
+ def steady(path, deadline):
135
+ clock[0] += 1.9
136
+ return VALUES[next(key for key in PATHS if PATHS[key] == path)]
137
+ with patch('network_provider.time.monotonic', side_effect=lambda: clock[0]):
138
+ fetch = Mock(side_effect=steady)
139
+ self.assertEqual(observe(INVENTORY, PUBLIC, fetch)['reason'], 'metadata_timeout')
140
+ self.assertEqual(fetch.call_count, 6)
141
+ self.assertEqual(fetch.call_args.args[1], 10)
142
+
143
+ def test_receipt_rejects_extra_fields_bad_types_and_contradictory_states(self):
144
+ result, _ = fixture()
145
+ changes = ({'secret': 'PRIVATE_SENTINEL'}, {'droplet_id': 123}, {'region': []},
146
+ {'private_gateway': 1}, {'private_subnet': '10.116.0.2/20'},
147
+ {'provider': 'other'}, {'reason': 'PRIVATE_SENTINEL'}, {'status': 'unverified'},
148
+ {'private_gateway_route': 'unverified'}, {'public_ipv4': '138.197.036.107'})
149
+ for changed in changes:
150
+ with self.subTest(changed=changed), self.assertRaises(ValueError):
151
+ validate_provider(dict(result, **changed))
152
+ failure = observe(INVENTORY, 'hostname', Mock())
153
+ for changed in ({'droplet_id': '123'}, {'private_gateway_route': 'not_observed'}, {'public_ipv4': None}):
154
+ with self.assertRaises(ValueError):
155
+ validate_provider(dict(failure, **changed))
156
+ mismatch, _ = fixture(expected='138.197.36.108')
157
+ with self.assertRaises(ValueError):
158
+ validate_provider(dict(mismatch, private_gateway_route='observed'))
159
+
160
+
161
+ if __name__ == '__main__':
162
+ unittest.main()
@@ -0,0 +1,153 @@
1
+ """Existing bridge evidence with full pools, foreign collisions and untrusted public receipts."""
2
+ import copy
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ import unittest
7
+
8
+ from identity_diagnostics import ReaderFailure
9
+ from network_diagnostics import decode, remote_source
10
+ from network_selected import KINDS, selected_validation
11
+ from network_test_fixture import Host, network
12
+
13
+
14
+ def fixture():
15
+ inventory = Host().inventory(None)
16
+ selected = network('fixture', '10.253.55.0/28')
17
+ inventory.update(complete=True, pool_source='engine_info', networks=inventory['networks'] + [selected])
18
+ bridge = 'br-' + selected['Id'][:12]
19
+ records = [('10.253.55.0/28', 'route_connected'), ('10.253.55.1/32', 'route_connected_source'),
20
+ ('10.253.55.1/32', 'route_local'), ('10.253.55.15/32', 'route_broadcast'),
21
+ ('10.253.55.0/28', 'address_network'), ('10.253.55.1/32', 'address_local'),
22
+ ('10.253.55.15/32', 'address_broadcast')]
23
+ inventory['host_records'] = [{'subnet': subnet, 'kind': kind, 'device': bridge} for subnet, kind in records]
24
+ inventory['host_excluded'] = sorted({row['subnet'] for row in inventory['host_records']})
25
+ inventory['excluded'] += inventory['host_excluded']
26
+ return inventory, selected
27
+
28
+
29
+ class SelectedTests(unittest.TestCase):
30
+ def test_self_routes_do_not_conflict_or_prove_an_outside_pool_reservation(self):
31
+ inventory, selected = fixture()
32
+ # No spare capacity in the only pool; that does not determine existing-network overlap.
33
+ inventory['pools'] = [{'base': '172.28.0.0/24', 'size': 24}]
34
+ result = selected_validation(inventory, selected, 'fixture')
35
+ self.assertEqual((result['ownership'], result['overlap'], result['reason']), ('matched', 'clear', ''))
36
+ self.assertEqual(result['self_record_count'], 7)
37
+ self.assertEqual(result['pool_membership'], 'outside_verified_pools')
38
+ inventory['pools'] = [{'base': '10.253.55.0/28', 'size': 28}]
39
+ self.assertEqual(selected_validation(inventory, selected, 'fixture')['pool_membership'],
40
+ 'inside_verified_pools')
41
+ inventory['pool_failure'] = 'pool_provenance_unknown'
42
+ self.assertEqual(selected_validation(inventory, selected, 'fixture')['overlap'], 'clear')
43
+ self.assertEqual(selected_validation(inventory, selected, 'fixture')['pool_membership'], 'unverified')
44
+
45
+ def test_identical_foreign_docker_prefix_is_preserved_after_self_exclusions(self):
46
+ inventory, selected = fixture()
47
+ inventory['networks'].append(network('foreign', '10.253.55.0/28'))
48
+ result = selected_validation(inventory, selected, 'fixture')
49
+ self.assertEqual(result['overlap'], 'conflict')
50
+ self.assertEqual(result['docker_conflicts'], ['10.253.55.0/28'])
51
+ self.assertNotIn('foreign', json.dumps(result))
52
+
53
+ def test_foreign_routes_and_dns_inside_self_subnet_are_never_excused(self):
54
+ for kind, device in (('route_connected', 'vpn0'), ('route_other', 'tailscale0'),
55
+ ('route_gateway', 'eth0'), ('resolver', ''), ('route_other', 'self')):
56
+ inventory, selected = fixture()
57
+ bridge = 'br-' + selected['Id'][:12]
58
+ inventory['host_records'].append({'subnet': '10.253.55.1/32', 'kind': kind,
59
+ 'device': bridge if device == 'self' else device})
60
+ with self.subTest(kind=kind, device=device):
61
+ result = selected_validation(inventory, selected, 'fixture')
62
+ self.assertEqual(result['overlap'], 'conflict')
63
+ self.assertEqual(result['host_conflicts'][0]['kind'], kind)
64
+ self.assertNotIn('device', result['host_conflicts'][0])
65
+
66
+ def test_missing_route_provenance_and_missing_bridge_facts_do_not_claim_clear(self):
67
+ for change, reason in (('missing', 'selected_bridge_records_missing'),
68
+ ('incomplete', 'host_records_incomplete')):
69
+ inventory, selected = fixture()
70
+ if change == 'missing':
71
+ inventory['host_records'] = []
72
+ inventory['host_excluded'] = []
73
+ else:
74
+ inventory['host_excluded'].append('10.108.0.0/20')
75
+ result = selected_validation(inventory, selected, 'fixture')
76
+ self.assertEqual((result['overlap'], result['reason']), ('unverified', reason))
77
+ inventory['networks'].append(network('foreign', '10.253.55.0/24'))
78
+ self.assertEqual(selected_validation(inventory, selected, 'fixture')['overlap'], 'conflict')
79
+
80
+ def test_ownership_and_unsupported_bridge_shapes_never_exempt_host_records(self):
81
+ changes = [({'Name': 'custom'}, 'selected_ownership_unverified'),
82
+ ({'Labels': {'com.docker.compose.project': 'fixture'}}, 'selected_ownership_unverified'),
83
+ ({'Driver': 'overlay'}, 'selected_bridge_unsupported'),
84
+ ({'EnableIPv6': True}, 'selected_bridge_unsupported'),
85
+ ({'Options': {'com.docker.network.bridge.name': 'custom0'}}, 'selected_bridge_unsupported')]
86
+ for settings, reason in changes:
87
+ inventory, selected = fixture()
88
+ selected.update(settings)
89
+ with self.subTest(settings=settings):
90
+ result = selected_validation(inventory, selected, 'fixture')
91
+ self.assertEqual((result['overlap'], result['reason']), ('unverified', reason))
92
+ self.assertEqual(result['self_record_count'], 0)
93
+ inventory, selected = fixture()
94
+ inventory['networks'].append(copy.deepcopy(selected))
95
+ self.assertEqual(selected_validation(inventory, selected, 'fixture')['reason'],
96
+ 'selected_ownership_unverified')
97
+ inventory, selected = fixture()
98
+ selected['IPAM']['Config'][0].update(IPRange='', AuxiliaryAddresses={})
99
+ self.assertEqual(selected_validation(inventory, selected, 'fixture')['overlap'], 'clear')
100
+ selected['IPAM']['Config'][0]['IPRange'] = '10.253.55.8/29'
101
+ self.assertEqual(selected_validation(inventory, selected, 'fixture')['reason'],
102
+ 'selected_bridge_unsupported')
103
+
104
+ def test_truncated_evidence_retains_conflict_without_unbounded_host_records(self):
105
+ inventory, selected = fixture()
106
+ for index in range(300):
107
+ inventory['host_records'].append({'subnet': '10.253.55.1/32', 'kind': 'route_other',
108
+ 'device': 'vpn%d' % index})
109
+ # Different device names collapse to the same safe public relation, not raw private inventory.
110
+ result = selected_validation(inventory, selected, 'fixture')
111
+ self.assertEqual(result['overlap'], 'conflict')
112
+ self.assertEqual(len(result['host_conflicts']), 1)
113
+ self.assertNotIn('vpn', json.dumps(result))
114
+ for index in range(16):
115
+ for kind in KINDS:
116
+ inventory['host_records'].append({'subnet': '10.253.55.%d/32' % index,
117
+ 'kind': kind, 'device': 'vpn0'})
118
+ for kind in KINDS:
119
+ inventory['host_records'].append({'subnet': '10.253.55.0/31', 'kind': kind, 'device': 'vpn0'})
120
+ inventory['host_records'].append({'subnet': '10.253.55.0/30', 'kind': kind, 'device': 'vpn0'})
121
+ inventory['host_excluded'] = sorted({row['subnet'] for row in inventory['host_records']})
122
+ result = selected_validation(inventory, selected, 'fixture')
123
+ self.assertEqual((result['overlap'], result['reason']), ('conflict', 'overlap_evidence_limit'))
124
+ self.assertEqual(len(result['host_conflicts']), 256)
125
+
126
+ def test_streamed_v2_receipt_binds_selected_network_and_rejects_false_clear_or_secret_fields(self):
127
+ inventory, selected = fixture()
128
+ source = remote_source('fixture').decode().replace(
129
+ 'inventory = collect(run, require_pools=False)', 'inventory = ' + repr(inventory))
130
+ process = subprocess.run([sys.executable, '-'], input=source.encode(), stdout=subprocess.PIPE,
131
+ stderr=subprocess.PIPE, timeout=5)
132
+ self.assertEqual(process.returncode, 0, process.stderr.decode())
133
+ receipt = decode(0, process.stdout, 'fixture')
134
+ self.assertEqual(receipt['networks'][0]['validation']['overlap'], 'clear')
135
+ for changed in ({'bridge': 'br-' + 'f' * 12}, {'self_record_count': 0},
136
+ {'pool_membership': 'inside_verified_pools'},
137
+ {'gateway': 184352513},
138
+ {'secret': 'PRIVATE_SENTINEL'}, {'docker_conflicts': ['10.253.55.0/28']},
139
+ {'host_conflicts': [{'subnet': '10.253.55.1/32', 'kind': 'PRIVATE_SENTINEL',
140
+ 'device_relation': 'foreign_device'}]}):
141
+ bad = copy.deepcopy(receipt)
142
+ bad['networks'][0]['validation'].update(changed)
143
+ with self.subTest(changed=changed), self.assertRaises(ReaderFailure):
144
+ decode(0, json.dumps(bad).encode(), 'fixture')
145
+ for changed in ({'name': 'another_default'}, {'driver': 'overlay'}, {'subnets': [184352512]}):
146
+ bad = copy.deepcopy(receipt)
147
+ bad['networks'][0].update(changed)
148
+ with self.subTest(changed=changed), self.assertRaises(ReaderFailure):
149
+ decode(0, json.dumps(bad).encode(), 'fixture')
150
+
151
+
152
+ if __name__ == '__main__':
153
+ unittest.main()
@@ -68,12 +68,55 @@ existing deployment identity but skips source sync, environment installation and
68
68
  package modules travel over SSH stdin and execute in memory; no helper file is installed on the host.
69
69
 
70
70
  The `backend-network-diagnostics-<job>-<attempt>` artifact contains `result.json` with schema
71
- `gowalk-cicd/backend-network-diagnostics.v1`. It records:
71
+ `gowalk-cicd/backend-network-diagnostics.v2`. It records:
72
72
 
73
73
  - Docker, Compose and Python versions with a UTC timestamp;
74
74
  - effective pool bases/sizes, their provenance and any typed pool refusal;
75
75
  - inventory completeness, network count and excluded IPv4 prefixes;
76
- - only the selected project's network IDs, names, Compose keys, subnets and endpoint counts.
76
+ - only the selected project's network IDs, names, Compose keys, subnets and endpoint counts;
77
+ - a `validation` for each selected network, separating observed IPv4 overlap from verified pool membership.
78
+
79
+ Selected-network validation binds an unambiguous automatic Compose name to its project/key labels.
80
+ It supports local ordinary IPv4 bridges with one RFC1918 subnet, an explicit usable gateway and no
81
+ custom options. Other shapes report `unverified`; labels alone never exempt a host route. The exact
82
+ network ID determines the bridge device, and only its expected kernel connected/local/broadcast and
83
+ address records count as self records. Connected and local routes and addresses must actually appear.
84
+ Foreign Docker prefixes, foreign host routes, resolver addresses and unexpected records on the selected
85
+ bridge remain conflicts even when their prefix is identical to a self record. The public receipt retains
86
+ only overlapping prefixes, fixed host-record kinds and device relationships, never foreign project names
87
+ or raw host inventory. Each conflict list is limited to 256 entries; a truncated list retains `conflict`.
88
+
89
+ `validation.overlap` is `clear`, `conflict` or `unverified`. `pool_membership` independently reports
90
+ `inside_verified_pools`, `outside_verified_pools` or `unverified`; it does not measure spare capacity.
91
+ Top-level `ok:true` means the inventory read completed, so consumers must inspect each validation.
92
+ An outside-pool network can have a clear observed overlap check while still lacking an address-policy
93
+ reservation. This receipt does not authorize that range or certify destinations hidden behind a default
94
+ route. Establish any deliberate host-local reservation through the owning infrastructure policy and
95
+ recheck the live network before asserting permanent suitability. Provider metadata can establish a
96
+ host's identity and attached private subnet; it does not delegate another range to Docker.
97
+
98
+ For a DigitalOcean deployment, select `diagnostic-provider: digitalocean` on that same workflow.
99
+ The default `none` performs no provider read. The optional `host_identity` reads only six fixed leaves
100
+ from `169.254.169.254:80` on the already verified deployment host: Droplet ID, region, public interface 0
101
+ IPv4 address, and private interface 0 IPv4 address/netmask/gateway. It never requests an index, bulk
102
+ metadata, user data, tokens or credentials. The reader allows no proxy, redirect, retry, authentication
103
+ or destination override; it bounds each response to 128 body bytes and 8 KiB/16 header fields, with
104
+ two seconds per request and ten seconds overall.
105
+
106
+ Positive binding requires the returned public address to equal the configured literal IPv4 deployment
107
+ host and a kernel local address. The returned private address and derived subnet must match the same
108
+ unique non-bridge kernel interface. A usable private gateway is retained as a metadata fact; its absence
109
+ from kernel gateway records does not invalidate host identity or claim that the gateway is being used.
110
+ `host_identity.status` separates `verified`, `mismatch`, `unavailable` and `unverified`; failure of this
111
+ optional read never rewrites the independently observed network-overlap result. Interface slot 0 is
112
+ not a VPC/peering inventory. Current supported Tailscale kernel routes remain part of the overlap check;
113
+ no Tailscale preferences, state files, account data or control-plane route claims are fetched.
114
+
115
+ These facts can support a deliberate current host-local reservation by the authorized infrastructure
116
+ owner, recorded against the exact host, existing network ID, subnet, gateway and diagnostic revision.
117
+ Such a decision must state its host-local routing scope and require revalidation before future routing
118
+ or advertisement changes. It does not invent a historical provider delegation, expand daemon pools,
119
+ modify a network or turn a route snapshot into global address-space proof.
77
120
 
78
121
  A routing refusal can retain safe engine/pool metadata with `complete:false` and `ok:false`. Such
79
122
  partial evidence never admits allocation. A successful diagnostic is a readback, not a deployment,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.154",
3
+ "version": "1.0.155",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,6 +24,11 @@ on:
24
24
  type: string
25
25
  description: For diagnostics, UTC YYYY-MM-DDTHH:MM:SSZ within the previous 24 hours
26
26
  required: false
27
+ diagnostic-provider:
28
+ type: choice
29
+ description: Optional host metadata for network diagnostics; never address-space authorization
30
+ default: none
31
+ options: [none, digitalocean]
27
32
 
28
33
  permissions:
29
34
  contents: read
@@ -65,6 +70,7 @@ jobs:
65
70
  with:
66
71
  operation: ${{ inputs.operation || 'deploy' }}
67
72
  diagnostic-since: ${{ inputs.diagnostic-since }}
73
+ diagnostic-provider: ${{ inputs.diagnostic-provider || 'none' }}
68
74
  ssh-key: ${{ secrets.BACKEND_DEPLOY_SSH_KEY }}
69
75
  host: ${{ vars.BACKEND_DEPLOY_HOST || '138.197.36.107' }}
70
76
  app-name: ${{ vars.BACKEND_APP_NAME || github.event.repository.name }}
Binary file