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.
- package/action/.daemux-version +1 -1
- package/android-action/.daemux-version +1 -1
- package/backend-action/.daemux-version +1 -1
- package/backend-action/action.yml +6 -0
- package/backend-action/network_diagnostics.py +32 -15
- package/backend-action/network_provider.py +213 -0
- package/backend-action/network_provider_test_fixture.py +30 -0
- package/backend-action/network_readback.py +12 -6
- package/backend-action/network_selected.py +96 -0
- package/backend-action/network_selected_receipt.py +60 -0
- package/backend-action/test_network_diagnostics.py +20 -0
- package/backend-action/test_network_provider.py +162 -0
- package/backend-action/test_network_selected.py +153 -0
- package/docs/BACKEND_NETWORKS.md +45 -2
- package/package.json +1 -1
- package/templates/deploy-backend.yml +6 -0
- package/templates/history.json.gz +0 -0
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.155
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.155
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
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.
|
|
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'])
|
|
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
|
|
52
|
+
result = {'schema': SCHEMA, 'ok': False, 'project': APP, 'failure': error.code}
|
|
45
53
|
result.update(getattr(error, 'details', {}))
|
|
46
54
|
except Exception:
|
|
47
|
-
result
|
|
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
|
-
|
|
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
|
|
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,
|
|
119
|
-
|
|
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
|
|
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
|
-
|
|
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()
|
package/docs/BACKEND_NETWORKS.md
CHANGED
|
@@ -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.
|
|
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
|
@@ -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
|