Merge ngit external-access PR 79ca68c1 into combined UAT candidate
Preserve current maintenance/session guards, Firewall UI and existing catalogs. Retain scoped guest access, publishing journeys and local Blossom integration. Normalize Blossom/router memory units to supported quadlet suffixes. Validation: 108 dashboard tests, 10 gateway policy tests, strict source catalog check. Integrated isolated backend qualification remains required before main.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
FROM docker.io/denoland/deno:debian-2.9.7
|
||||
WORKDIR /app
|
||||
# Upstream MIT source is pinned independently from the application version.
|
||||
ADD https://codeload.github.com/hzrd149/blossom-server/tar.gz/a492dc61c4a581bbd0992546b2aec6f9aa543f75 /tmp/upstream.tar.gz
|
||||
RUN echo 'd4f4ab9cbbf1b6d72d8cdb68fbb0b7b55dbe0c7e4a7414819f5c96dafd0af3fd /tmp/upstream.tar.gz' | sha256sum -c - && tar -xzf /tmp/upstream.tar.gz --strip-components=1 -C /app && rm /tmp/upstream.tar.gz
|
||||
COPY patch.ts startup.ts ./
|
||||
COPY ui/ ./archy-ui/
|
||||
RUN deno run --allow-read=/app --allow-write=/app patch.ts && deno cache --frozen main.ts startup.ts && deno bundle --no-config --platform browser archy-ui/app.ts -o archy-ui/app.js
|
||||
# Fetch native dependencies at build time, not on a user's first upload.
|
||||
RUN deno eval 'await import("@libsql/client"); await import("sharp")'
|
||||
ENV BLOSSOM_REQUIRE_CONFIG=1
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["deno", "run", "--cached-only", "--frozen", "-A", "--deny-run", "/app/startup.ts"]
|
||||
@@ -0,0 +1,32 @@
|
||||
// Narrow packaging changes against the pinned upstream source. Fail instead of
|
||||
// silently losing the bridge or re-enabling automatic deletion after an update.
|
||||
const mainPath = '/app/main.ts';
|
||||
let main = await Deno.readTextFile(mainPath);
|
||||
const prune = 'const pruneEnabled = config.storage.rules.length > 0 ||\n config.storage.removeWhenNoOwners;';
|
||||
if (!main.includes(prune)) throw new Error('Upstream prune integration changed');
|
||||
main = main.replace(prune, '// Archipelago owns retention: no automatic deletion of published assets.\nconst pruneEnabled = false;');
|
||||
await Deno.writeTextFile(mainPath, main);
|
||||
const serverPath = '/app/src/server.ts';
|
||||
let server = await Deno.readTextFile(serverPath);
|
||||
const marker = ' app.route("/", buildBlossomRouter(db, storage, config));';
|
||||
if (!server.includes(marker)) throw new Error('Upstream route integration changed');
|
||||
server = server.replace(marker, `
|
||||
// Uploaded HTML/SVG must never execute with the app gate or signer origin.
|
||||
app.use('*', async (c, next) => {
|
||||
await next();
|
||||
if (/^\\/[a-f0-9]{64}(?:\\.[a-zA-Z0-9]+)?$/.test(c.req.path)) {
|
||||
c.header('Content-Security-Policy', "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'");
|
||||
c.header('Content-Disposition', 'attachment');
|
||||
c.header('X-Content-Type-Options', 'nosniff');
|
||||
}
|
||||
});
|
||||
// Static local UI and the canonical host-managed signer bridge only.
|
||||
app.get('/', async c => c.html(await Deno.readTextFile('/app/archy-ui/index.html')));
|
||||
app.get('/app.js', async c => c.body(await Deno.readTextFile('/app/archy-ui/app.js'), 200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-store' }));
|
||||
app.get('/nostr-provider.js', async c => {
|
||||
try { return c.body(await Deno.readTextFile('/bridge/nostr-provider.js'), 200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache, no-store, must-revalidate' }); }
|
||||
catch { return c.text('Archipelago signer bridge is not installed', 503); }
|
||||
});
|
||||
app.get('/healthz', c => c.json({ ready: true, storage: 'local' }));
|
||||
${marker}`);
|
||||
await Deno.writeTextFile(serverPath, server);
|
||||
@@ -0,0 +1,21 @@
|
||||
// Public profile keys only; no private keys or dashboard credentials enter this
|
||||
// container. Changes to the node's identity allowlist take effect on restart.
|
||||
const keys = (Deno.env.get('ARCHY_BLOSSOM_PUBKEYS') ?? '').split(',').filter(Boolean);
|
||||
if (!keys.length || keys.some(k => !/^[a-f0-9]{64}$/.test(k))) throw new Error('Create a profile identity in Archipelago before starting Blossom');
|
||||
const config = JSON.parse(await Deno.readTextFile('/config/config.json'));
|
||||
config.host = '0.0.0.0'; config.port = 3000;
|
||||
config.database = { path: '/data/sqlite.db' };
|
||||
config.storage = { backend: 'local', local: { dir: '/data/blobs' }, removeWhenNoOwners: false, rules: [{ type: '*', expiration: '100 years', pubkeys: keys }] };
|
||||
config.upload = { enabled: true, requireAuth: true, requirePubkeyInRule: true, maxSize: 16777216, workers: 1 };
|
||||
config.delete = { requireAuth: true };
|
||||
config.list = { enabled: true, requireAuth: true, allowListOthers: false };
|
||||
config.mirror = { enabled: false, requireAuth: true };
|
||||
config.media = { enabled: false, requireAuth: true, requirePubkeyInRule: true };
|
||||
config.report = { enabled: false };
|
||||
config.landing = { enabled: false };
|
||||
config.dashboard = { enabled: false };
|
||||
// No URL/host facts are baked into the UI. BUD-11 uses the actual request host
|
||||
// unless the operator explicitly configured the server's canonical domain.
|
||||
await Deno.writeTextFile('/tmp/blossom-config.json', JSON.stringify(config));
|
||||
Deno.args.splice(0, Deno.args.length, '/tmp/blossom-config.json');
|
||||
await import('./main.ts');
|
||||
@@ -0,0 +1,87 @@
|
||||
import { sha256 } from 'npm:@noble/hashes@2.0.1/sha2.js';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
nostr?: { getPublicKey(): Promise<string>; signEvent(event: unknown): Promise<Record<string, unknown>> };
|
||||
archipelagoNostr?: { selectIdentity?(): Promise<void> };
|
||||
}
|
||||
}
|
||||
const element = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
const fileInput = element<HTMLInputElement>('file');
|
||||
const approve = element<HTMLInputElement>('approve');
|
||||
const upload = element<HTMLButtonElement>('upload');
|
||||
const refresh = element<HTMLButtonElement>('refresh');
|
||||
let pubkey = '';
|
||||
let working = false;
|
||||
function update() {
|
||||
upload.disabled = working || !pubkey || !approve.checked || !fileInput.files?.length;
|
||||
refresh.disabled = working || !pubkey;
|
||||
fileInput.disabled = working;
|
||||
element<HTMLButtonElement>('identity').disabled = working;
|
||||
}
|
||||
async function perform(fn: () => Promise<void>) {
|
||||
working = true; update(); element('status').textContent = '';
|
||||
try { await fn(); } catch (e) { element('status').textContent = e instanceof Error ? e.message : 'Request failed'; }
|
||||
finally { working = false; update(); }
|
||||
}
|
||||
async function auth(action: string, hash?: string) {
|
||||
if (!window.nostr) throw new Error('Archipelago signer is unavailable. Reinstall the app bridge; never enter a private key here.');
|
||||
if (await window.nostr.getPublicKey() !== pubkey) throw new Error('Identity changed. Choose your identity and review the file again.');
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const tags = [['t', action], ['expiration', String(now + 300)], ['server', location.hostname]];
|
||||
if (hash) tags.push(['x', hash]);
|
||||
const signed = await window.nostr.signEvent({ kind: 24242, created_at: now, tags, content: `Authorize local Blossom ${action}` });
|
||||
if (signed.pubkey !== pubkey) throw new Error('Signer returned another identity. Nothing was sent.');
|
||||
return 'Nostr ' + btoa(JSON.stringify(signed));
|
||||
}
|
||||
async function chooseIdentity() { await perform(async () => {
|
||||
pubkey = ''; approve.checked = false; element('files').replaceChildren();
|
||||
element('pubkey').textContent = 'Choose a profile in the Archipelago signer…';
|
||||
if (!window.nostr) throw new Error('Archipelago signer is unavailable');
|
||||
await window.archipelagoNostr?.selectIdentity?.();
|
||||
const key = await window.nostr.getPublicKey();
|
||||
if (!/^[a-f0-9]{64}$/.test(key)) throw new Error('Invalid signer identity');
|
||||
pubkey = key; approve.checked = false;
|
||||
element('pubkey').textContent = key; element('files').replaceChildren();
|
||||
}); }
|
||||
element('identity').onclick = chooseIdentity;
|
||||
fileInput.onchange = () => {
|
||||
approve.checked = false;
|
||||
const file = fileInput.files?.[0];
|
||||
element('file-review').textContent = file ? `${file.name} · ${file.size} bytes · ${file.type || 'unknown type'}` : 'Choose a file up to 16 MiB.';
|
||||
update();
|
||||
};
|
||||
approve.onchange = update;
|
||||
upload.onclick = () => perform(async () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (!file || !approve.checked || file.size > 16777216) throw new Error('Choose and approve a file up to 16 MiB');
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const hash = Array.from(sha256(bytes), b => b.toString(16).padStart(2, '0')).join('');
|
||||
const authorization = await auth('upload', hash);
|
||||
const response = await fetch('/upload', { method: 'PUT', headers: { Authorization: authorization, 'Content-Type': file.type || 'application/octet-stream' }, body: bytes, credentials: 'same-origin', redirect: 'error' });
|
||||
if (!response.ok) throw new Error(`Local upload failed (${response.status}). No external copy was requested.`);
|
||||
const descriptor = await response.json();
|
||||
if (descriptor.sha256 !== hash || descriptor.size !== bytes.length) throw new Error('Storage returned an unexpected file descriptor');
|
||||
approve.checked = false;
|
||||
element('status').textContent = `Stored on this node. SHA-256: ${hash}. No Nostr announcement was published.`;
|
||||
});
|
||||
refresh.onclick = () => perform(async () => {
|
||||
const authorization = await auth('list');
|
||||
const response = await fetch(`/list/${pubkey}?limit=100`, { headers: { Authorization: authorization }, credentials: 'same-origin', redirect: 'error' });
|
||||
if (!response.ok) throw new Error(`Could not list files (${response.status})`);
|
||||
const files = await response.json();
|
||||
if (!Array.isArray(files)) throw new Error('Unexpected file list');
|
||||
const list = element('files'); list.replaceChildren();
|
||||
for (const file of files.slice(0, 100)) {
|
||||
if (!/^[a-f0-9]{64}$/.test(file.sha256)) continue;
|
||||
const item = document.createElement('li');
|
||||
const link = document.createElement('a');
|
||||
link.href = '/' + file.sha256; link.download = file.sha256;
|
||||
link.textContent = `${file.sha256} · ${file.size} bytes`;
|
||||
item.append(link); list.append(item);
|
||||
}
|
||||
});
|
||||
|
||||
// Request the canonical chooser once on opening. Cancellation leaves the page
|
||||
// usable with a retry button; this never signs an upload or publishes an event.
|
||||
void chooseIdentity();
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-src http: https:; base-uri 'none'; form-action 'none'"><title>Blossom · Archipelago</title><script data-app-id="blossom" data-no-nip98 src="/nostr-provider.js?v=tab-signer-v4"></script><script type="module" src="/app.js"></script><style>*{box-sizing:border-box}input{max-width:100%}body{font:16px system-ui;background:#11151c;color:#eee;max-width:760px;margin:auto;padding:32px}h1{font-size:32px}section{padding:24px;border:1px solid #384150;border-radius:16px;margin:20px 0}button,input{font:inherit}button{padding:10px 16px;border:0;border-radius:8px;background:#d9a86c;color:#161616;cursor:pointer}button:disabled{opacity:.45;cursor:default}p{line-height:1.5;color:#c8ccd4;overflow-wrap:anywhere}li{overflow-wrap:anywhere}code{overflow-wrap:anywhere}label{display:block;margin:16px 0}a{color:#e9b983}#status{white-space:pre-wrap}</style></head><body><h1>Blossom on your node</h1><p>Store files with your Archipelago identity. Files stay on this node. Uploading here does not publish a Nostr event or send a copy to another server.</p><section><h2>Your identity</h2><button id="identity">Choose identity</button><p id="pubkey">Choose a profile identity to manage its files.</p><p>After removing a profile from Archipelago, restart Blossom to revoke that profile’s uploads.</p></section><section><h2>Store a file</h2><input id="file" type="file"><p id="file-review">Choose a file up to 16 MiB. Review it before storing.</p><label><input id="approve" type="checkbox"> I want to store this exact file on this node.</label><button id="upload" disabled>Store locally</button><p>External access and public replication are separate choices in Publish a website. Public copies may be impossible to erase.</p></section><section><h2>Your files</h2><button id="refresh" disabled>Load my files</button><ul id="files"></ul></section><p id="status" role="status"></p></body></html>
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM docker.io/library/python:3.13-slim-bookworm@sha256:a1165e272e578941b84abc79e4ab38a0305cd12803a5c4247979ac7655f4d641
|
||||
COPY download.py /build/download.py
|
||||
RUN python3 /build/download.py && rm -rf /build
|
||||
COPY router.py /app/router.py
|
||||
ENV XDG_DATA_HOME=/data XDG_CONFIG_HOME=/data/config PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
ENTRYPOINT ["python3", "/app/router.py"]
|
||||
@@ -0,0 +1,28 @@
|
||||
import hashlib
|
||||
import io
|
||||
import platform
|
||||
import tarfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
arch = {'x86_64': 'amd64', 'aarch64': 'arm64'}[platform.machine()]
|
||||
pins = {
|
||||
'frp': ('0.71.0', {'amd64': '84f27e39f11169f7adcef8e8b70c9329de17747b1f14dad9fb95eef5682ea716', 'arm64': 'f33c293c275d8fc68c654b6fba8f10b2551d6463d09a9fc9cffb7227eae82266'}),
|
||||
'caddy': ('2.11.7', {'amd64': '727b91701a392de6ebc5027509f548bf39979e5216340d0faed8fa5e69c84f8b', 'arm64': 'd8fc6d179a5d283028a472a5618564f6ad8a86fed513e64f032b3b0b7cc45e42'}),
|
||||
}
|
||||
for name, (version, digests) in pins.items():
|
||||
repo = 'fatedier/frp' if name == 'frp' else 'caddyserver/caddy'
|
||||
url = f'https://github.com/{repo}/releases/download/v{version}/{name}_{version}_linux_{arch}.tar.gz'
|
||||
with urllib.request.urlopen(url, timeout=120) as response:
|
||||
data = response.read(64 * 1024 * 1024 + 1)
|
||||
if hashlib.sha256(data).hexdigest() != digests[arch]:
|
||||
raise ValueError(f'{name} archive checksum mismatch')
|
||||
binary = 'frpc' if name == 'frp' else 'caddy'
|
||||
member = f'frp_{version}_linux_{arch}/frpc' if name == 'frp' else 'caddy'
|
||||
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
|
||||
info = archive.getmember(member)
|
||||
if not info.isfile() or info.size > 128 * 1024 * 1024:
|
||||
raise ValueError('Invalid binary archive member')
|
||||
output = Path('/usr/local/bin') / binary
|
||||
output.write_bytes(archive.extractfile(info).read())
|
||||
output.chmod(0o755)
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Supervise node-owned frpc and Caddy. Configuration is supplied by Setup.
|
||||
|
||||
No local management listener or arbitrary TCP forwarding. Invalid or removed
|
||||
configuration stops the owned children. Certificates persist in /data.
|
||||
"""
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
DOMAIN = re.compile(r'(?=.{1,253}\Z)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}\Z')
|
||||
NAME = re.compile(r'[a-z0-9][a-z0-9-]{0,47}\Z')
|
||||
|
||||
|
||||
def render(config):
|
||||
if config.get('schema') != 1:
|
||||
raise ValueError('Unsupported configuration')
|
||||
gateway = config['gateway']
|
||||
host = gateway['host']
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
if not DOMAIN.fullmatch(host):
|
||||
raise ValueError('Invalid gateway hostname')
|
||||
port = gateway['port']
|
||||
if type(port) is not int or not 1024 <= port <= 65535:
|
||||
raise ValueError('Invalid gateway control port')
|
||||
node = gateway['node_id']
|
||||
if not NAME.fullmatch(node):
|
||||
raise ValueError('Invalid enrollment name')
|
||||
for key in ('transport_token', 'enrollment_token'):
|
||||
if not isinstance(gateway[key], str) or not 32 <= len(gateway[key]) <= 256:
|
||||
raise ValueError('Invalid enrollment credential')
|
||||
pem = gateway['ca_pem']
|
||||
if len(pem) > 16384 or not pem.startswith('-----BEGIN CERTIFICATE-----') or 'PRIVATE KEY' in pem:
|
||||
raise ValueError('A gateway CA certificate is required')
|
||||
server_name = gateway['tls_server_name']
|
||||
try:
|
||||
ipaddress.ip_address(server_name)
|
||||
except ValueError:
|
||||
if not DOMAIN.fullmatch(server_name):
|
||||
raise ValueError('Invalid gateway TLS name')
|
||||
mode = config.get('certificate_mode', 'public')
|
||||
if mode not in ('public', 'test'):
|
||||
raise ValueError('Invalid certificate mode')
|
||||
routes = config['routes']
|
||||
if not isinstance(routes, list) or len(routes) > 32:
|
||||
raise ValueError('Too many routes')
|
||||
caddy = '{\n admin off\n auto_https disable_redirects\n skip_install_trust\n}\n'
|
||||
proxies = []
|
||||
domains, names = set(), set()
|
||||
for route in routes:
|
||||
name, domain = route['id'], route['domain']
|
||||
if not NAME.fullmatch(name) or not DOMAIN.fullmatch(domain) or name in names or domain in domains:
|
||||
raise ValueError('Invalid or duplicate route')
|
||||
if domain not in gateway.get('domains', []):
|
||||
raise ValueError('Domain is not assigned by enrollment')
|
||||
names.add(name); domains.add(domain)
|
||||
address = ipaddress.IPv6Address(route['fips_address'])
|
||||
if address not in ipaddress.IPv6Network('fd00::/8'):
|
||||
raise ValueError('A FIPS ULA address is required')
|
||||
upstream = route['port']
|
||||
app_id = route.get('app_id')
|
||||
if app_id is not None:
|
||||
if not isinstance(app_id, str) or not NAME.fullmatch(app_id) or name != 'app-' + app_id or type(upstream) is not int or not 1024 <= upstream <= 65535:
|
||||
raise ValueError('Invalid catalogue app route')
|
||||
identity_header = f'X-Archipelago-App {app_id}'
|
||||
else:
|
||||
if type(upstream) is not int or not 32000 <= upstream < 32032:
|
||||
raise ValueError('Only published website listeners are supported')
|
||||
identity_header = f'X-Archipelago-Website {name}'
|
||||
tls = 'tls internal' if mode == 'test' else 'tls {\n issuer acme {\n disable_http_challenge\n }\n }'
|
||||
caddy += f'https://{domain}:8443 {{\n bind 127.0.0.1\n {tls}\n reverse_proxy http://[{address}]:{upstream} {{\n header_up {identity_header}\n }}\n}}\n'
|
||||
proxies.append({'name': name, 'type': 'https', 'localIP': '127.0.0.1', 'localPort': 8443, 'customDomains': [domain]})
|
||||
frpc = {'serverAddr': host, 'serverPort': port, 'user': node,
|
||||
'metadatas': {'enrollment_token': gateway['enrollment_token']},
|
||||
'auth': {'method': 'token', 'token': gateway['transport_token'], 'additionalScopes': ['HeartBeats', 'NewWorkConns']},
|
||||
'transport': {'tls': {'enable': True, 'trustedCaFile': '/tmp/router/gateway.crt', 'serverName': server_name}},
|
||||
'loginFailExit': False, 'proxies': proxies, 'log': {'to': 'console', 'level': 'error'}}
|
||||
return caddy, frpc, pem
|
||||
|
||||
|
||||
def main():
|
||||
os.umask(0o077)
|
||||
root = Path('/tmp/router'); root.mkdir(exist_ok=True)
|
||||
source = Path('/config/router.json')
|
||||
children = []
|
||||
stopping = False
|
||||
previous = None
|
||||
|
||||
def stop_children():
|
||||
for child in children:
|
||||
if child.poll() is None:
|
||||
child.terminate()
|
||||
for child in children:
|
||||
try: child.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill(); child.wait()
|
||||
children.clear()
|
||||
|
||||
def shutdown(*_):
|
||||
nonlocal stopping
|
||||
stopping = True
|
||||
|
||||
signal.signal(signal.SIGTERM, shutdown)
|
||||
signal.signal(signal.SIGINT, shutdown)
|
||||
try:
|
||||
while not stopping:
|
||||
try:
|
||||
if source.stat().st_size > 131072:
|
||||
raise ValueError('Oversized config')
|
||||
raw = source.read_bytes()
|
||||
caddy, frpc, pem = render(json.loads(raw))
|
||||
if previous != raw or any(child.poll() is not None for child in children):
|
||||
stop_children()
|
||||
(root/'gateway.crt').write_text(pem)
|
||||
(root/'frpc.json').write_text(json.dumps(frpc))
|
||||
(root/'Caddyfile').write_text(caddy)
|
||||
if frpc['proxies']:
|
||||
for command in [ ['/usr/local/bin/caddy', 'validate', '--config', str(root/'Caddyfile'), '--adapter', 'caddyfile'], ['/usr/local/bin/frpc', 'verify', '-c', str(root/'frpc.json')] ]:
|
||||
subprocess.run(command, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15)
|
||||
for command in [['/usr/local/bin/caddy', 'run', '--config', str(root/'Caddyfile'), '--adapter', 'caddyfile'], ['/usr/local/bin/frpc', '-c', str(root/'frpc.json')]]:
|
||||
children.append(subprocess.Popen(command))
|
||||
previous = raw
|
||||
(root/'status.json').write_text(json.dumps({'configured': True, 'routes': len(frpc['proxies']), 'certificate_mode': json.loads(raw).get('certificate_mode', 'public'), 'externally_verified': False}))
|
||||
except (OSError, ValueError, KeyError, TypeError, AttributeError, subprocess.SubprocessError):
|
||||
stop_children(); previous = None
|
||||
for name in ('frpc.json', 'Caddyfile', 'gateway.crt'):
|
||||
(root/name).unlink(missing_ok=True)
|
||||
(root/'status.json').write_text(json.dumps({'configured': False, 'externally_verified': False}))
|
||||
(root/'heartbeat').touch()
|
||||
time.sleep(2)
|
||||
finally:
|
||||
stop_children()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user