gowalk-cicd 1.0.207 → 1.0.209
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/action/action.yml +14 -0
- package/action/scripts/mobile_artifact_files.py +14 -0
- package/action/scripts/native_preparation.py +166 -0
- package/backend-action/action.yml +1 -1
- package/backend-action/network_host.py +69 -0
- package/backend-action/network_inventory.py +4 -2
- package/backend-action/network_projection.py +25 -8
- package/backend-action/network_readback.py +12 -4
- package/docs/BACKEND_SSH_GATE.md +8 -3
- package/docs/CANDIDATE_ARTIFACTS.md +42 -0
- package/package.json +1 -1
- package/src/config/schema.mjs +5 -0
- package/src/config/settings.mjs +4 -1
- package/templates/deploy-backend.yml +1 -1
- package/templates/history.json.gz +0 -0
package/README.md
CHANGED
|
@@ -1390,10 +1390,12 @@ Identity failure logs and source Compose files do not establish this applied sta
|
|
|
1390
1390
|
### Read-only bridge and VPN prerequisite diagnostics
|
|
1391
1391
|
|
|
1392
1392
|
Backend Deploy also offers `operation: network-diagnostics`, with an exact `diagnostic-service`, deployed
|
|
1393
|
-
`diagnostic-build-sha`, owned `diagnostic-network`, proposed namespace-only private IPv4 `/24` in
|
|
1393
|
+
`diagnostic-build-sha`, owned `diagnostic-network`, proposed namespace-only private IPv4 `/24` or IPv6 ULA `/64` in
|
|
1394
1394
|
`diagnostic-pool`, exact `diagnostic-vpn-service`, and optional `diagnostic-provider: digitalocean`.
|
|
1395
1395
|
It reports the owned bridge/IPAM, observed pool conflicts, UDP 500/4500 ownership, bound host identity and
|
|
1396
|
-
kernel/firewall facts in a closed v3 artifact
|
|
1396
|
+
kernel/firewall facts in a closed v3 artifact for IPv4 and v4 for IPv6. The IPv6 result also distinguishes
|
|
1397
|
+
host and owned-gateway default-route and forwarding observations; actual external connectivity stays unverified.
|
|
1398
|
+
The allocator stays retired; a completed read is never
|
|
1397
1399
|
deployment, port reservation, globally unused address space or tunnel proof. Effective firewall ingress and
|
|
1398
1400
|
unobserved capabilities remain unverified. The [trusted-host procedure](docs/BACKEND_SSH_GATE.md#owned-bridge-and-vpn-prerequisite-readback)
|
|
1399
1401
|
defines scope, retained evidence and the required host runtime adoption before this operation is available.
|
package/action/action.yml
CHANGED
|
@@ -10,6 +10,10 @@ description: >
|
|
|
10
10
|
consumers. App Store provisioning profiles are reconciled at runtime.
|
|
11
11
|
|
|
12
12
|
inputs:
|
|
13
|
+
native-preparation-manifest:
|
|
14
|
+
description: Tracked manifest for app-owned native dependency preparation
|
|
15
|
+
required: false
|
|
16
|
+
default: ''
|
|
13
17
|
candidate-binary:
|
|
14
18
|
description: Verified candidate artifact directory supplied by the package workflow
|
|
15
19
|
required: false
|
|
@@ -233,6 +237,16 @@ runs:
|
|
|
233
237
|
sudo xcode-select -s "$XCODE_PATH"
|
|
234
238
|
xcodebuild -version
|
|
235
239
|
|
|
240
|
+
- name: Prepare manifest-bound native dependencies
|
|
241
|
+
if: ${{ inputs.candidate-binary == '' && inputs.native-preparation-manifest != '' }}
|
|
242
|
+
shell: bash
|
|
243
|
+
env:
|
|
244
|
+
NATIVE_PREPARATION_MANIFEST: ${{ inputs.native-preparation-manifest }}
|
|
245
|
+
run: |
|
|
246
|
+
python3 "$SWIFT_APP_ACTION/scripts/native_preparation.py" prepare \
|
|
247
|
+
"$GITHUB_WORKSPACE" "$NATIVE_PREPARATION_MANIFEST" "$RUNNER_TEMP/native-preparation.json"
|
|
248
|
+
printf 'NATIVE_PREPARATION_MANIFEST=%s\n' "$NATIVE_PREPARATION_MANIFEST" >> "$GITHUB_ENV"
|
|
249
|
+
|
|
236
250
|
- name: Describe native dependency cache
|
|
237
251
|
if: ${{ inputs.candidate-binary == '' }}
|
|
238
252
|
id: native_cache_scope
|
|
@@ -95,6 +95,12 @@ def pack(platform: str, directory: Path) -> None:
|
|
|
95
95
|
raise ValueError("mobile_artifact_binary_ambiguous")
|
|
96
96
|
(directory / "export").mkdir()
|
|
97
97
|
shutil.copyfile(binaries[0], directory / "export" / "app.ipa")
|
|
98
|
+
manifest = os.environ.get("NATIVE_PREPARATION_MANIFEST", "")
|
|
99
|
+
if manifest:
|
|
100
|
+
import native_preparation
|
|
101
|
+
receipt = temp / "native-preparation.json"
|
|
102
|
+
native_preparation.verify(Path(os.environ["GITHUB_WORKSPACE"]).resolve(), manifest, receipt, outputs=True)
|
|
103
|
+
shutil.copyfile(receipt, directory / "native-preparation.json")
|
|
98
104
|
symbols = ["firebase-symbols", "dart-symbols/ios"]
|
|
99
105
|
else:
|
|
100
106
|
binary = Path(os.environ["MOBILE_ARTIFACT_BINARY"])
|
|
@@ -120,6 +126,14 @@ def verify(platform: str, directory: Path) -> dict:
|
|
|
120
126
|
or value.get("credential_fingerprints") != credentials(platform)
|
|
121
127
|
or value.get("files") != files(directory)):
|
|
122
128
|
raise ValueError("mobile_artifact_inputs_mismatch")
|
|
129
|
+
if platform == "ios":
|
|
130
|
+
manifest = os.environ.get("NATIVE_PREPARATION_MANIFEST", "")
|
|
131
|
+
if manifest:
|
|
132
|
+
import native_preparation
|
|
133
|
+
native_preparation.verify(Path(os.environ["GITHUB_WORKSPACE"]).resolve(), manifest,
|
|
134
|
+
directory / "native-preparation.json")
|
|
135
|
+
elif "native-preparation.json" in value["files"]:
|
|
136
|
+
raise ValueError("mobile_artifact_inputs_mismatch")
|
|
123
137
|
expected = set(IOS_ENV if platform == "ios" else ANDROID_ENV)
|
|
124
138
|
environment = value.get("environment", {})
|
|
125
139
|
if set(environment) not in (expected, expected - {SOURCE_PLIST}) or any(
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Prepare and attest tracked, app-authored native build dependencies."""
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
SCHEMA = 'gowalk-cicd/native-preparation.v1'
|
|
13
|
+
RECEIPT = 'gowalk-cicd/native-preparation-receipt.v1'
|
|
14
|
+
MAX_FILES = 10000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def digest(path):
|
|
18
|
+
with path.open('rb') as stream:
|
|
19
|
+
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def command(*args, cwd):
|
|
23
|
+
return subprocess.run(args, cwd=cwd, check=True, stdout=subprocess.PIPE,
|
|
24
|
+
stderr=subprocess.DEVNULL, text=True).stdout
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def path_at(root, name):
|
|
28
|
+
if not isinstance(name, str) or len(name) > 500 or not re.fullmatch(r'[A-Za-z0-9_./-]+', name):
|
|
29
|
+
raise ValueError('native_preparation_path_invalid')
|
|
30
|
+
path = PurePosixPath(name)
|
|
31
|
+
if path.is_absolute() or any(part in ('', '.', '..') for part in name.split('/')):
|
|
32
|
+
raise ValueError('native_preparation_path_invalid')
|
|
33
|
+
if 'creds' in path.parts or '.git' in path.parts or any(part.startswith('.env') for part in path.parts):
|
|
34
|
+
raise ValueError('native_preparation_path_invalid')
|
|
35
|
+
result = root.joinpath(*path.parts)
|
|
36
|
+
current = root
|
|
37
|
+
for part in path.parts:
|
|
38
|
+
current = current / part
|
|
39
|
+
if current.is_symlink():
|
|
40
|
+
raise ValueError('native_preparation_symlink')
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def tracked(root, name):
|
|
45
|
+
path = path_at(root, name)
|
|
46
|
+
if not path.is_file():
|
|
47
|
+
raise ValueError('native_preparation_input_absent')
|
|
48
|
+
command('git', 'ls-files', '--error-unmatch', '--', name, cwd=root)
|
|
49
|
+
command('git', 'diff', '--quiet', 'HEAD', '--', name, cwd=root)
|
|
50
|
+
return digest(path)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load(root, name):
|
|
54
|
+
manifest_hash = tracked(root, name)
|
|
55
|
+
path = path_at(root, name)
|
|
56
|
+
if path.stat().st_size > 65536:
|
|
57
|
+
raise ValueError('native_preparation_manifest_invalid')
|
|
58
|
+
data = json.loads(path.read_text())
|
|
59
|
+
if (not isinstance(data, dict) or set(data) != {'schema', 'script', 'inputs', 'outputs'}
|
|
60
|
+
or data['schema'] != SCHEMA or not isinstance(data['inputs'], list)
|
|
61
|
+
or not isinstance(data['outputs'], list) or not data['outputs']
|
|
62
|
+
or len(data['inputs']) > 100 or len(data['outputs']) > 100):
|
|
63
|
+
raise ValueError('native_preparation_manifest_invalid')
|
|
64
|
+
inputs = [name, data['script'], *data['inputs']]
|
|
65
|
+
if len(set(inputs)) != len(inputs) or len(set(data['outputs'])) != len(data['outputs']):
|
|
66
|
+
raise ValueError('native_preparation_manifest_invalid')
|
|
67
|
+
hashes = {item: tracked(root, item) for item in inputs}
|
|
68
|
+
if hashes[name] != manifest_hash or not os.access(path_at(root, data['script']), os.X_OK):
|
|
69
|
+
raise ValueError('native_preparation_script_invalid')
|
|
70
|
+
for output in data['outputs']:
|
|
71
|
+
path_at(root, output)
|
|
72
|
+
if command('git', 'ls-files', '-z', '--', output, cwd=root):
|
|
73
|
+
raise ValueError('native_preparation_output_tracked')
|
|
74
|
+
command('git', 'check-ignore', '-q', '--', output, cwd=root)
|
|
75
|
+
return data, hashes
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def output_tree(root, name):
|
|
79
|
+
path = path_at(root, name)
|
|
80
|
+
if not path.exists():
|
|
81
|
+
raise ValueError('native_preparation_output_absent')
|
|
82
|
+
paths = [path] if path.is_file() else sorted(path.rglob('*'))
|
|
83
|
+
entries = []
|
|
84
|
+
for item in paths:
|
|
85
|
+
if item.is_symlink():
|
|
86
|
+
raise ValueError('native_preparation_symlink')
|
|
87
|
+
if item.is_file():
|
|
88
|
+
entries.append([item.relative_to(root).as_posix(), digest(item)])
|
|
89
|
+
if len(entries) > MAX_FILES:
|
|
90
|
+
raise ValueError('native_preparation_output_limit')
|
|
91
|
+
elif not item.is_dir():
|
|
92
|
+
raise ValueError('native_preparation_output_invalid')
|
|
93
|
+
if not entries:
|
|
94
|
+
raise ValueError('native_preparation_output_absent')
|
|
95
|
+
return {'path': name, 'files': len(entries), 'sha256': hashlib.sha256(
|
|
96
|
+
json.dumps(entries, ensure_ascii=True, separators=(',', ':')).encode()).hexdigest()}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def identity(root, name):
|
|
100
|
+
data, inputs = load(root, name)
|
|
101
|
+
return data, {'schema': RECEIPT, 'manifest': name, 'inputs': inputs}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def prepare(root, manifest, receipt):
|
|
105
|
+
data, result = identity(root, manifest)
|
|
106
|
+
command('git', 'diff', '--quiet', 'HEAD', '--', cwd=root)
|
|
107
|
+
allowed = ('PATH', 'HOME', 'TMPDIR', 'RUNNER_TEMP', 'APP_ROBOT_TASK_SCRATCH', 'DEVELOPER_DIR', 'SDKROOT',
|
|
108
|
+
'CI', 'GITHUB_ACTIONS', 'GITHUB_RUN_ID', 'GITHUB_RUN_ATTEMPT', 'GITHUB_JOB')
|
|
109
|
+
environment = {key: os.environ[key] for key in allowed if key in os.environ}
|
|
110
|
+
if environment.get('GITHUB_ACTIONS') == 'true':
|
|
111
|
+
environment['CI'] = 'true'
|
|
112
|
+
environment['GITHUB_WORKSPACE'] = str(root)
|
|
113
|
+
subprocess.run([str(path_at(root, data['script']))], cwd=root, env=environment, check=True)
|
|
114
|
+
command('git', 'diff', '--quiet', 'HEAD', '--', cwd=root)
|
|
115
|
+
_, after = identity(root, manifest)
|
|
116
|
+
if result != after:
|
|
117
|
+
raise ValueError('native_preparation_input_changed')
|
|
118
|
+
result['toolchain'] = command('xcodebuild', '-version', cwd=root).strip()
|
|
119
|
+
result['outputs'] = [output_tree(root, name) for name in data['outputs']]
|
|
120
|
+
receipt.write_text(json.dumps(result, sort_keys=True, indent=2) + '\n')
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def verify(root, manifest, receipt, *, outputs=False):
|
|
124
|
+
data, expected = identity(root, manifest)
|
|
125
|
+
if receipt.is_symlink() or not receipt.is_file() or receipt.stat().st_size > 1024 * 1024:
|
|
126
|
+
raise ValueError('native_preparation_receipt_invalid')
|
|
127
|
+
actual = json.loads(receipt.read_text())
|
|
128
|
+
if (not isinstance(actual, dict) or set(actual) != set(expected) | {'toolchain', 'outputs'}
|
|
129
|
+
or any(actual.get(k) != v for k, v in expected.items())
|
|
130
|
+
or not isinstance(actual.get('toolchain'), str) or not actual['toolchain']
|
|
131
|
+
or not isinstance(actual.get('outputs'), list) or len(actual['outputs']) != len(data['outputs'])):
|
|
132
|
+
raise ValueError('native_preparation_receipt_invalid')
|
|
133
|
+
for expected_path, item in zip(data['outputs'], actual['outputs']):
|
|
134
|
+
if (not isinstance(item, dict) or set(item) != {'path', 'files', 'sha256'}
|
|
135
|
+
or item['path'] != expected_path or type(item['files']) is not int
|
|
136
|
+
or not 0 < item['files'] <= MAX_FILES or not isinstance(item['sha256'], str)
|
|
137
|
+
or len(item['sha256']) != 64 or any(c not in '0123456789abcdef' for c in item['sha256'])):
|
|
138
|
+
raise ValueError('native_preparation_receipt_invalid')
|
|
139
|
+
if outputs and (actual['outputs'] != [output_tree(root, name) for name in data['outputs']]
|
|
140
|
+
or actual['toolchain'] != command('xcodebuild', '-version', cwd=root).strip()):
|
|
141
|
+
raise ValueError('native_preparation_output_changed')
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def main():
|
|
145
|
+
parser = argparse.ArgumentParser()
|
|
146
|
+
parser.add_argument('operation', choices=['prepare', 'verify', 'verify-outputs'])
|
|
147
|
+
parser.add_argument('root', type=Path)
|
|
148
|
+
parser.add_argument('manifest')
|
|
149
|
+
parser.add_argument('receipt', type=Path)
|
|
150
|
+
args = parser.parse_args()
|
|
151
|
+
try:
|
|
152
|
+
root = args.root.resolve(strict=True)
|
|
153
|
+
if args.operation == 'prepare':
|
|
154
|
+
prepare(root, args.manifest, args.receipt)
|
|
155
|
+
else:
|
|
156
|
+
verify(root, args.manifest, args.receipt, outputs=args.operation == 'verify-outputs')
|
|
157
|
+
except (ValueError, OSError, subprocess.SubprocessError, TypeError, KeyError) as error:
|
|
158
|
+
code = str(error) if isinstance(error, ValueError) and str(error).startswith('native_preparation_') else \
|
|
159
|
+
'native_preparation_failed'
|
|
160
|
+
print(code, file=sys.stderr)
|
|
161
|
+
return 1
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == '__main__':
|
|
166
|
+
sys.exit(main())
|
|
@@ -27,7 +27,7 @@ inputs:
|
|
|
27
27
|
required: false
|
|
28
28
|
default: default
|
|
29
29
|
diagnostic-pool:
|
|
30
|
-
description: Proposed strict private IPv4 /24 for a VPN
|
|
30
|
+
description: Proposed strict private IPv4 /24 or IPv6 ULA /64 for a VPN namespace; never a host allocation.
|
|
31
31
|
required: false
|
|
32
32
|
default: ""
|
|
33
33
|
diagnostic-vpn-service:
|
|
@@ -105,6 +105,75 @@ def local_docker():
|
|
|
105
105
|
raise NetworkFailure('network_scope_mismatch')
|
|
106
106
|
|
|
107
107
|
|
|
108
|
+
def _ipv6_default(raw):
|
|
109
|
+
if raw is None:
|
|
110
|
+
return 'unverified'
|
|
111
|
+
try:
|
|
112
|
+
rows = json.loads(raw)
|
|
113
|
+
if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
|
|
114
|
+
raise ValueError()
|
|
115
|
+
found = False
|
|
116
|
+
for row in rows:
|
|
117
|
+
if row.get('dst', 'default') not in ('default', '::/0'):
|
|
118
|
+
raise ValueError()
|
|
119
|
+
flags = row.get('flags', [])
|
|
120
|
+
if not isinstance(flags, list) or any(not isinstance(flag, str) for flag in flags):
|
|
121
|
+
raise ValueError()
|
|
122
|
+
kind = row.get('type', 'unicast')
|
|
123
|
+
if kind not in ('unicast', 'blackhole', 'unreachable', 'prohibit', 'throw'):
|
|
124
|
+
raise ValueError()
|
|
125
|
+
device = row.get('dev')
|
|
126
|
+
if kind == 'unicast' and (not isinstance(device, str) or not re.fullmatch(r'[A-Za-z0-9_.-]{1,15}', device)):
|
|
127
|
+
raise ValueError()
|
|
128
|
+
found |= kind == 'unicast' and device != 'lo' and 'linkdown' not in flags
|
|
129
|
+
return 'present' if found else 'absent'
|
|
130
|
+
except (ValueError, TypeError, UnicodeError):
|
|
131
|
+
return 'unverified'
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def ipv6_routes(containers, app, service, bridge, namespace_safe=False):
|
|
135
|
+
host = _ipv6_default(run(['ip', '-j', '-6', 'route', 'show', 'default'], optional=True))
|
|
136
|
+
host_forward = run(['cat', '/proc/sys/net/ipv6/conf/all/forwarding'], optional=True)
|
|
137
|
+
forwarding = (None if host_forward is None or host_forward.strip() not in (b'0', b'1')
|
|
138
|
+
else host_forward.strip() == b'1')
|
|
139
|
+
matches = [row for row in containers if row['service'] == service and row['running']]
|
|
140
|
+
if len(matches) > 1:
|
|
141
|
+
raise NetworkFailure('network_ambiguous')
|
|
142
|
+
if not matches:
|
|
143
|
+
gateway = 'not_running'
|
|
144
|
+
gateway_forwarding = None
|
|
145
|
+
elif not namespace_safe:
|
|
146
|
+
gateway, gateway_forwarding = 'unverified', None
|
|
147
|
+
else:
|
|
148
|
+
template = ('{"project":{{json (index .Config.Labels "com.docker.compose.project")}},'
|
|
149
|
+
'"service":{{json (index .Config.Labels "com.docker.compose.service")}},'
|
|
150
|
+
'"mode":{{json .HostConfig.NetworkMode}},"key":{{json .NetworkSettings.SandboxKey}}}')
|
|
151
|
+
raw = run(['docker', 'inspect', '--format', template, matches[0]['id']], optional=True)
|
|
152
|
+
try:
|
|
153
|
+
record = json.loads(raw) if raw else {}
|
|
154
|
+
if not isinstance(record, dict):
|
|
155
|
+
raise ValueError()
|
|
156
|
+
if record and (record.get('project') != app or record.get('service') != service
|
|
157
|
+
or record.get('mode') != bridge['name']):
|
|
158
|
+
raise NetworkFailure('network_changed')
|
|
159
|
+
path = record.get('key', '')
|
|
160
|
+
except (ValueError, UnicodeError):
|
|
161
|
+
path = ''
|
|
162
|
+
if not isinstance(path, str):
|
|
163
|
+
path = ''
|
|
164
|
+
if not re.fullmatch(r'/var/run/docker/netns/[0-9a-f]{12,64}', path):
|
|
165
|
+
gateway, gateway_forwarding = 'unverified', None
|
|
166
|
+
else:
|
|
167
|
+
entry = ['nsenter', '--net=' + path, '--']
|
|
168
|
+
raw = run([*entry, 'ip', '-j', '-6', 'route', 'show', 'default'], optional=True)
|
|
169
|
+
gateway = _ipv6_default(raw)
|
|
170
|
+
raw = run([*entry, 'cat', '/proc/sys/net/ipv6/conf/all/forwarding'], optional=True)
|
|
171
|
+
gateway_forwarding = None if raw is None or raw.strip() not in (b'0', b'1') else raw.strip() == b'1'
|
|
172
|
+
return {'host_ipv6_default_route': host, 'gateway_ipv6_default_route': gateway,
|
|
173
|
+
'host_ipv6_forwarding': forwarding, 'gateway_ipv6_forwarding': gateway_forwarding,
|
|
174
|
+
'ipv6_external_connectivity': 'unverified'}
|
|
175
|
+
|
|
176
|
+
|
|
108
177
|
def namespace(containers, app, vpn_service, bridge):
|
|
109
178
|
info = run(['docker', 'info', '--format', '{"os":{{json .OSType}},"security":{{json .SecurityOptions}}}'])
|
|
110
179
|
try:
|
|
@@ -79,9 +79,11 @@ def selected(rows, app, key):
|
|
|
79
79
|
'ipam': [{'subnet': x['Subnet'], 'gateway': x.get('Gateway', '')} for x in row['ipam']]}
|
|
80
80
|
|
|
81
81
|
|
|
82
|
-
def host_networks():
|
|
82
|
+
def host_networks(include_ipv6=False):
|
|
83
83
|
addresses = decode(run(['ip', '-j', 'address', 'show']))
|
|
84
84
|
routes = decode(run(['ip', '-j', '-4', 'route', 'show', 'table', 'all']))
|
|
85
|
+
if include_ipv6:
|
|
86
|
+
routes += decode(run(['ip', '-j', '-6', 'route', 'show', 'table', 'all']))
|
|
85
87
|
result = []
|
|
86
88
|
try:
|
|
87
89
|
for row in addresses:
|
|
@@ -102,7 +104,7 @@ def host_networks():
|
|
|
102
104
|
|
|
103
105
|
|
|
104
106
|
def overlap(rows, bridge, host_rows, pool):
|
|
105
|
-
proposed = ipaddress.
|
|
107
|
+
proposed = ipaddress.ip_network(pool, strict=True)
|
|
106
108
|
own = [ipaddress.ip_network(item['subnet']) for item in bridge['ipam']]
|
|
107
109
|
foreign = [ipaddress.ip_network(item['Subnet']) for row in rows if row['id'] != bridge['network_id']
|
|
108
110
|
for item in row['ipam'] or []]
|
|
@@ -5,6 +5,7 @@ import re
|
|
|
5
5
|
from runtime_projection import READER_VERSION, PROJECT, SERVICE, SHA, CID
|
|
6
6
|
|
|
7
7
|
SCHEMA = 'gowalk-cicd/backend-network-diagnostics.v3'
|
|
8
|
+
IPV6_SCHEMA = 'gowalk-cicd/backend-network-diagnostics.v4'
|
|
8
9
|
FAILURES = frozenset({'invalid_scope', 'network_absent', 'network_ambiguous', 'network_scope_mismatch',
|
|
9
10
|
'network_changed', 'network_invalid', 'build_mismatch', 'host_binding_mismatch', 'command_timeout',
|
|
10
11
|
'output_limit', 'command_failed', 'transport_failure', 'remote_invalid', 'artifact_write_failed',
|
|
@@ -23,18 +24,22 @@ def scope(app, service, expected, network, pool, vpn_service, host, provider):
|
|
|
23
24
|
if any(not isinstance(value, str) or not re.fullmatch(pattern, value) for value, pattern in fields):
|
|
24
25
|
raise NetworkFailure('invalid_scope')
|
|
25
26
|
try:
|
|
26
|
-
candidate = ipaddress.
|
|
27
|
+
candidate = ipaddress.ip_network(pool, strict=True)
|
|
27
28
|
except (ValueError, TypeError):
|
|
28
29
|
raise NetworkFailure('invalid_scope') from None
|
|
29
30
|
private = ('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16')
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
v4 = (candidate.version == 4 and candidate.prefixlen == 24
|
|
32
|
+
and any(candidate.subnet_of(ipaddress.IPv4Network(x)) for x in private))
|
|
33
|
+
v6 = (candidate.version == 6 and candidate.prefixlen == 64
|
|
34
|
+
and candidate.subnet_of(ipaddress.IPv6Network('fc00::/7')))
|
|
35
|
+
if not (v4 or v6) or provider not in ('none', 'digitalocean'):
|
|
32
36
|
raise NetworkFailure('invalid_scope')
|
|
33
37
|
|
|
34
38
|
|
|
35
39
|
def envelope(app, service, expected, network, pool, vpn_service, host, provider, failure='', *,
|
|
36
40
|
reader_version=READER_VERSION):
|
|
37
|
-
|
|
41
|
+
schema = IPV6_SCHEMA if isinstance(pool, str) and ':' in pool else SCHEMA
|
|
42
|
+
return {'schema': schema, 'reader_version': reader_version, 'ok': not failure, 'failure': failure,
|
|
38
43
|
'project': app, 'build_service': service, 'expected_build_sha': expected, 'network_key': network,
|
|
39
44
|
'proposed_pool': pool, 'vpn_service': vpn_service, 'expected_host': host, 'provider': provider}
|
|
40
45
|
|
|
@@ -65,7 +70,7 @@ def bridge_record(row, app, network):
|
|
|
65
70
|
raise NetworkFailure('remote_invalid') from None
|
|
66
71
|
|
|
67
72
|
|
|
68
|
-
def observation_records(result):
|
|
73
|
+
def observation_records(result, ipv6=False):
|
|
69
74
|
closed(result['overlap'], ('selected_bridge_foreign_docker', 'selected_bridge_host',
|
|
70
75
|
'pool_selected_bridge', 'pool_foreign_docker', 'pool_host_routes', 'pool_host_addresses',
|
|
71
76
|
'default_gateway_destinations'))
|
|
@@ -83,8 +88,12 @@ def observation_records(result):
|
|
|
83
88
|
if ((row['status'] == 'free') == observed
|
|
84
89
|
or row['status'] in ('app_vpn', 'foreign') and not row['docker_binding_observed']):
|
|
85
90
|
raise NetworkFailure('remote_invalid')
|
|
86
|
-
|
|
87
|
-
|
|
91
|
+
fields = {'docker_linux_rootful', 'host_ipv4_forwarding', 'xfrm_read',
|
|
92
|
+
'netfilter_nat_read', 'selected_bridge_masquerade', 'vpn_settings'}
|
|
93
|
+
if ipv6:
|
|
94
|
+
fields |= {'host_ipv6_default_route', 'gateway_ipv6_default_route', 'host_ipv6_forwarding',
|
|
95
|
+
'gateway_ipv6_forwarding', 'ipv6_external_connectivity'}
|
|
96
|
+
closed(result['namespace'], fields)
|
|
88
97
|
for key in ('docker_linux_rootful', 'host_ipv4_forwarding', 'selected_bridge_masquerade'):
|
|
89
98
|
if result['namespace'][key] is not None and type(result['namespace'][key]) is not bool:
|
|
90
99
|
raise NetworkFailure('remote_invalid')
|
|
@@ -93,6 +102,14 @@ def observation_records(result):
|
|
|
93
102
|
raise NetworkFailure('remote_invalid')
|
|
94
103
|
if result['namespace']['vpn_settings'] not in ('absent', 'isolated_net_admin', 'other', 'unverified'):
|
|
95
104
|
raise NetworkFailure('remote_invalid')
|
|
105
|
+
if ipv6:
|
|
106
|
+
row = result['namespace']
|
|
107
|
+
if (row['host_ipv6_default_route'] not in ('present', 'absent', 'unverified')
|
|
108
|
+
or row['gateway_ipv6_default_route'] not in ('present', 'absent', 'not_running', 'unverified')
|
|
109
|
+
or any(row[key] is not None and type(row[key]) is not bool
|
|
110
|
+
for key in ('host_ipv6_forwarding', 'gateway_ipv6_forwarding'))
|
|
111
|
+
or row['ipv6_external_connectivity'] != 'unverified'):
|
|
112
|
+
raise NetworkFailure('remote_invalid')
|
|
96
113
|
|
|
97
114
|
|
|
98
115
|
def host_records(result, provider):
|
|
@@ -146,6 +163,6 @@ def validate(result, app, service, expected, network, pool, vpn_service, host, p
|
|
|
146
163
|
or not re.fullmatch(r'\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ', result['observed_at'])):
|
|
147
164
|
raise NetworkFailure('remote_invalid')
|
|
148
165
|
bridge_record(result['bridge'], app, network)
|
|
149
|
-
observation_records(result)
|
|
166
|
+
observation_records(result, ipv6=':' in pool)
|
|
150
167
|
host_records(result, provider)
|
|
151
168
|
return result
|
|
@@ -26,23 +26,31 @@ def application(app, service, expected):
|
|
|
26
26
|
return rows
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def snapshot(app, service, expected):
|
|
29
|
+
def snapshot(app, service, expected, ipv6=False):
|
|
30
30
|
return {'application': application(app, service, expected), 'networks': network_inventory.networks(),
|
|
31
|
-
'host': network_inventory.host_networks(), 'bindings': network_inventory.port_bindings(),
|
|
31
|
+
'host': network_inventory.host_networks(include_ipv6=ipv6), 'bindings': network_inventory.port_bindings(),
|
|
32
32
|
'listeners': network_host.listeners()}
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def collect(app, service, expected, network, pool, vpn_service, host, provider):
|
|
36
36
|
scope(app, service, expected, network, pool, vpn_service, host, provider)
|
|
37
37
|
network_host.local_docker()
|
|
38
|
-
|
|
38
|
+
ipv6 = ':' in pool
|
|
39
|
+
original = snapshot(app, service, expected, ipv6=ipv6)
|
|
39
40
|
bridge = network_inventory.selected(original['networks'], app, network)
|
|
40
41
|
host_result = network_metadata.observe(host, original['host'], provider)
|
|
41
42
|
firewall, masquerade, netfilter = network_host.firewall(bridge)
|
|
42
43
|
namespace = network_host.namespace(original['application'], app, vpn_service, bridge)
|
|
43
44
|
namespace.update(netfilter_nat_read=netfilter, selected_bridge_masquerade=masquerade)
|
|
44
|
-
|
|
45
|
+
safe = namespace['vpn_settings'] == 'isolated_net_admin'
|
|
46
|
+
routes = network_host.ipv6_routes(original['application'], app, vpn_service, bridge,
|
|
47
|
+
namespace_safe=safe) if ipv6 else None
|
|
48
|
+
if (snapshot(app, service, expected, ipv6=ipv6) != original
|
|
49
|
+
or ipv6 and network_host.ipv6_routes(original['application'], app, vpn_service, bridge,
|
|
50
|
+
namespace_safe=safe) != routes):
|
|
45
51
|
raise NetworkFailure('network_changed')
|
|
52
|
+
if routes is not None:
|
|
53
|
+
namespace.update(routes)
|
|
46
54
|
result = dict(envelope(app, service, expected, network, pool, vpn_service, host, provider),
|
|
47
55
|
observed_build_sha=expected, bridge=bridge, host=host_result, firewall=firewall, namespace=namespace,
|
|
48
56
|
overlap=network_inventory.overlap(original['networks'], bridge, original['host'], pool),
|
package/docs/BACKEND_SSH_GATE.md
CHANGED
|
@@ -107,7 +107,7 @@ shared-database substitution.
|
|
|
107
107
|
`operation: network-diagnostics` is a read-only operation. The network allocator remains retired.
|
|
108
108
|
Select `diagnostic-service` (the existing API), `diagnostic-network` (the existing Compose bridge key,
|
|
109
109
|
default `default`), `diagnostic-vpn-service` (default `vpn`), `expected-build-sha`, and `diagnostic-pool`:
|
|
110
|
-
a strict RFC1918 IPv4 `/24` proposed only for the VPN container namespace. It creates no
|
|
110
|
+
a strict RFC1918 IPv4 `/24` or IPv6 ULA `/64` proposed only for the VPN container namespace. It creates no reservation,
|
|
111
111
|
network, route, firewall rule, container, certificate or service. Do not use this pool as Compose IPAM.
|
|
112
112
|
The generated workflow transports `diagnostic-build-sha` separately so a workflow-only commit can inspect
|
|
113
113
|
the API build actually deployed, rather than claiming its own checkout SHA is running.
|
|
@@ -123,7 +123,7 @@ released `network_readback.py`. It accepts no uploaded Python or app-mirror help
|
|
|
123
123
|
alone cannot install this host operation: install the integrity-verified published runtime first. A missing
|
|
124
124
|
host reader returns `network_reader_unavailable` with no host reader version; it is not a network verdict.
|
|
125
125
|
|
|
126
|
-
The closed `gowalk-cicd/backend-network-diagnostics.v3` result is retained as
|
|
126
|
+
The closed `gowalk-cicd/backend-network-diagnostics.v3` IPv4 result, or `v4` for IPv6, is retained as
|
|
127
127
|
`backend-network-diagnostics-<job>-<attempt>/result.json`. `ok: true` means the bounded read completed;
|
|
128
128
|
it never means a VPN is provisioned, traffic is reachable, a pool is globally unused or a tunnel works.
|
|
129
129
|
The reader binds the existing API's `BUILD_SHA`, checks its exact project/service/directory labels, and
|
|
@@ -150,12 +150,17 @@ for client compatibility, so prove adoption with the install receipt and the for
|
|
|
150
150
|
only NET_ADMIN plus optional NET_BIND_SERVICE/CHOWN/SETUID/SETGID/SETPCAP for bootstrap and privilege dropping,
|
|
151
151
|
and namespace-only forwarding/redirect settings. This observes grants, not that a daemon actually dropped privileges.
|
|
152
152
|
An absent VPN reports `absent`; these observations do not test creating a namespace or applying capabilities.
|
|
153
|
+
- An IPv6 request additionally compares the proposed ULA against host IPv6 routes, addresses and Docker subnets.
|
|
154
|
+
It reports host and owned-gateway IPv6 default-route and forwarding observations independently; missing tools or
|
|
155
|
+
inaccessible namespaces remain `unverified`, and an absent gateway reports `not_running`. A default route is not
|
|
156
|
+
a usable Internet path: `ipv6_external_connectivity` stays `unverified` until an independent traffic exercise.
|
|
153
157
|
- `firewall` reports observed INPUT/FORWARD policies and explicit UDP ACCEPT rules. Effective ingress
|
|
154
158
|
remains `unverified`: chained rules, nftables and upstream/provider firewalls need actual external traffic proof.
|
|
155
159
|
|
|
156
160
|
Foreign network ownership/configuration, process identities, raw firewall rules, environment values and
|
|
157
161
|
private bindings never leave the host. Foreign subnet data is used privately only to calculate overlap flags.
|
|
158
|
-
The reader
|
|
162
|
+
The IPv6 reader enters only the verified owned container's Docker network namespace using host `ip` and `cat`; it
|
|
163
|
+
executes no container code, reads no logs and contacts no provider management API.
|
|
159
164
|
No retry, downgrade, unrestricted SSH or historical inventory can replace a refused current read.
|
|
160
165
|
Preserve bridge and database identities, assess the observations within their stated scope, then separately
|
|
161
166
|
verify the actual namespaced gateway configuration, IKEv2 negotiation and encrypted forwarded traffic.
|
|
@@ -70,3 +70,45 @@ and pinned cache programs as release builds. Cache Pods and `.factory/toolchain/
|
|
|
70
70
|
helper's lockfile/Ruby/Xcode/architecture identity; `pod install --deployment` and committed locks
|
|
71
71
|
remain required. Beyond that conditional dependency priming, no compile warm-up, machine affinity, reservation
|
|
72
72
|
or factory wait protocol is added.
|
|
73
|
+
|
|
74
|
+
## App-owned native preparation
|
|
75
|
+
|
|
76
|
+
A native iOS app whose Xcode graph consumes generated, lockfile-pinned local frameworks may set
|
|
77
|
+
`mobile.ios.native-preparation-manifest` in `.gowalk-cicd.yml` to a tracked JSON file:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"schema": "gowalk-cicd/native-preparation.v1",
|
|
82
|
+
"script": "tools/ios/prepare-libraries.sh",
|
|
83
|
+
"inputs": ["tools/ios/library.lock.json"],
|
|
84
|
+
"outputs": [".factory/cache/library/ios/Example.xcframework"]
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
All names are repository-relative. The manifest, executable script and declared inputs must be
|
|
89
|
+
regular tracked files matching the frozen checkout. The tracked tree must remain unchanged through
|
|
90
|
+
preparation. Output trees must be gitignored, contain no tracked files and cannot contain symlinks.
|
|
91
|
+
The script itself must pin and verify its downloaded inputs, must not run tests, and must
|
|
92
|
+
produce all declared outputs with the active Xcode toolchain. The helper executes it as an argument,
|
|
93
|
+
not as an interpolated shell command. App-authored source and this build recipe are subject to the
|
|
94
|
+
same review and trusted-candidate policy as Xcode build phases.
|
|
95
|
+
The child receives a narrow environment, including the nonsecret GitHub run, attempt and job
|
|
96
|
+
identity so a recipe can distinguish a fresh build from same-job cache reuse; credentials and
|
|
97
|
+
unrelated workflow variables are not forwarded.
|
|
98
|
+
|
|
99
|
+
The action runs preparation after selecting Xcode and before Xcode project detection on an actual
|
|
100
|
+
macOS build. It records the source and output hashes and Xcode version, then checks those outputs
|
|
101
|
+
again before packing a candidate. The preparation receipt travels as an authenticated candidate
|
|
102
|
+
artifact file. Promotion verifies its manifest and source hashes along with the binary's existing
|
|
103
|
+
source, credential and artifact checks, and does not rebuild or require generated frameworks on
|
|
104
|
+
Linux. Missing, stale or malformed evidence fails closed. The full tracked mobile source, including
|
|
105
|
+
all scripts, lockfiles and configuration, remains part of the candidate fingerprint; the manifest
|
|
106
|
+
is not a substitute for reviewing a dependency recipe or testing the produced software.
|
|
107
|
+
|
|
108
|
+
Custom validation workflows that need the same graph can invoke the shipped helper before their
|
|
109
|
+
native dependency guard:
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
python3 .github/actions/swift-app/scripts/native_preparation.py prepare \
|
|
113
|
+
"$GITHUB_WORKSPACE" tools/ios/native-deps.json "$RUNNER_TEMP/native-preparation.json"
|
|
114
|
+
```
|
package/package.json
CHANGED
package/src/config/schema.mjs
CHANGED
|
@@ -44,6 +44,11 @@ function validateNamedSettings(config) {
|
|
|
44
44
|
if (candidate !== undefined && typeof candidate !== 'boolean') {
|
|
45
45
|
throw new MigrationRepair('candidate_setting_requires_boolean', '.gowalk-cicd.yml');
|
|
46
46
|
}
|
|
47
|
+
const manifest = config.mobile?.ios?.['native-preparation-manifest'];
|
|
48
|
+
if (manifest !== undefined && (typeof manifest !== 'string' || !/^[A-Za-z0-9_.\/-]+$/.test(manifest) ||
|
|
49
|
+
manifest.startsWith('/') || manifest.split('/').some(part => !part || part === '.' || part === '..'))) {
|
|
50
|
+
throw new MigrationRepair('native_preparation_manifest_path_invalid', '.gowalk-cicd.yml');
|
|
51
|
+
}
|
|
47
52
|
for (const platform of ['ios', 'android']) {
|
|
48
53
|
const value = config.mobile?.[platform];
|
|
49
54
|
const allowed = fields.filter(path => path[1] === platform).map(path => path[2]);
|
package/src/config/settings.mjs
CHANGED
|
@@ -6,7 +6,8 @@ const ios = ['jobs', 'ios', 'steps', 'items', 'id:ios', 'with'];
|
|
|
6
6
|
const android = ['jobs', 'android', 'steps', 'items', 'id:android', 'with'];
|
|
7
7
|
const backend = ['jobs', 'deploy', 'steps', 'items', 'name:Deploy backend', 'with'];
|
|
8
8
|
const inputs = {
|
|
9
|
-
ios: ['project', 'workspace', 'scheme', 'configuration', 'bundle-id', 'team-id', 'firebase-app-id'
|
|
9
|
+
ios: ['project', 'workspace', 'scheme', 'configuration', 'bundle-id', 'team-id', 'firebase-app-id',
|
|
10
|
+
'native-preparation-manifest'],
|
|
10
11
|
android: ['project-dir', 'module', 'variant', 'firebase-app-id', 'dart-defines'],
|
|
11
12
|
backend: ['ssh-key', 'host', 'app-name', 'api-domain', 'backend-dir', 'compose-file', 'health-path', 'runtime-env',
|
|
12
13
|
'public-health-url', 'public-base-url', 'expected-build-sha', 'ingress-required', 'ingress-token'],
|
|
@@ -16,6 +17,8 @@ export function settingsPaths(file) {
|
|
|
16
17
|
if (file === 'deploy.yml') return [
|
|
17
18
|
{ name: ['mobile', 'platforms'], path: mobile },
|
|
18
19
|
...inputs.ios.map(key => ({ name: ['mobile', 'ios', key], path: [...ios, key] })),
|
|
20
|
+
{ name: ['mobile', 'ios', 'native-preparation-manifest'],
|
|
21
|
+
path: ['jobs', 'ios', 'env', 'NATIVE_PREPARATION_MANIFEST'] },
|
|
19
22
|
...inputs.android.map(key => ({ name: ['mobile', 'android', key], path: [...android, key] })),
|
|
20
23
|
];
|
|
21
24
|
if (file === 'deploy-backend.yml') return inputs.backend.map(key => ({
|
|
Binary file
|