5 Commits
4 changed files with 252 additions and 2 deletions

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
FrameBridge public origin on GERANAS
This deployment template extends the existing nginx public origin. Render the two IPv4 placeholders with the selected GERANAS LAN address and CLOUD proxy address, then verify the complete rendered config before applying it. The existing loopback origin and private Tailscale HTTPS route are retained.
The new LAN socket serves the same website, downloads, signed APT indexes and existing Gitea routes. Its access list permits only the CLOUD proxy and loopback requests. The MCP, authentication and broker services are outside this server.
The public client connection terminates HTTPS at CLOUD. CLOUD connects to this origin over the trusted local network using HTTP, matching the existing local reverse proxy architecture. This configuration does not claim end-to-end TLS. Release signatures and pinned package hashes remain independently verifiable.
Deployment: preserve the existing config and a verified public generation, compare the intended four-line diff, run nginx -t against the rendered config, and signal only framebridge-web.service's nginx master with HUP. If validation fails, restore the preserved config and signal HUP again. Check loopback access, CLOUD access and denial from a different LAN client. Do not change firewall rules, Tailscale policy, DNS, certificates or other services as part of this template.
The product release source and signed v0.3.3 tag remain at a52625219e5d7eeadcc0c91d1d0fd92108e6b51a. This operations branch records only the web-origin deployment configuration.
+69
View File
@@ -0,0 +1,69 @@
daemon off;
worker_processes 1;
pid /run/framebridge-publisher/nginx.pid;
error_log stderr warn;
events { worker_connections 256; }
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log off;
server_tokens off;
sendfile on;
keepalive_timeout 30;
client_body_temp_path /run/framebridge-publisher/client-body;
proxy_temp_path /run/framebridge-publisher/proxy;
fastcgi_temp_path /run/framebridge-publisher/fastcgi;
uwsgi_temp_path /run/framebridge-publisher/uwsgi;
scgi_temp_path /run/framebridge-publisher/scgi;
map $http_upgrade $connection_upgrade { default upgrade; '' close; }
server {
listen 127.0.0.1:8088;
listen @GERANAS_LAN_IP@:8088;
allow 127.0.0.1;
allow @CLOUD_LAN_IP@;
deny all;
server_name framebridge.betancourt.one;
root /run/framebridge-publisher/site;
index index.html;
autoindex off;
client_max_body_size 64m;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; media-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" always;
location = /git { return 308 /git/; }
location /git/ {
rewrite ^ $request_uri;
rewrite ^/git/api/v1/repos/framebridge/framebridge(/.*)?$ /api/v1/repos/gerardo/framebridge$1 break;
rewrite ^/git/framebridge/framebridge(\.git)?(/.*)?$ /gerardo/framebridge$1$2 break;
rewrite ^/(git($|/))?(.*) /$3 break;
proxy_pass http://127.0.0.1:3005$uri;
proxy_http_version 1.1;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 120s;
proxy_request_buffering off;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
}
location = /debian { return 308 /debian/; }
location /debian/ {
alias /run/framebridge-publisher/debian/;
autoindex off;
limit_except GET HEAD { deny all; }
}
location = /downloads { return 308 /downloads/; }
location /downloads/ {
alias /run/framebridge-publisher/downloads/;
autoindex off;
limit_except GET HEAD { deny all; }
}
location / {
try_files $uri $uri/ =404;
limit_except GET HEAD { deny all; }
}
}
}
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Apply the reviewed public static overlay with per-file backups and readback."""
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import shutil
import socket
import stat
import subprocess
import tempfile
STAGE = Path('/var/lib/framebridge-release-publication/20261028-r2')
ORIGIN = Path('/srv/geranas-services/framebridge-public')
MANIFEST_SHA = '6bbdcacd9745af413546074b80619590a3b0982119c4fbf5586ff9e806794289'
COMMIT = 'a52625219e5d7eeadcc0c91d1d0fd92108e6b51a'
JOURNAL = STAGE / 'ACTIVATION-RECEIPT.json'
BACKUP = STAGE / 'pre-activation-backup'
def require(value, message):
if not value:
raise ValueError(message)
def digest(path):
h = hashlib.sha256()
with path.open('rb') as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
h.update(chunk)
return h.hexdigest()
def path_safe(path):
require(path.is_absolute(), 'Absolute path required')
for parent in (path, *path.parents):
if parent.exists() or parent.is_symlink():
s = parent.lstat()
require(not stat.S_ISLNK(s.st_mode), 'Symlink path rejected')
require(s.st_uid == 0 and not s.st_mode & 0o022, 'Untrusted writable path')
def verify(path, record):
require(path.is_file() and not path.is_symlink(), 'Expected regular public file')
require(path.stat().st_size == record['bytes'] and digest(path) == record['sha256'],
'Public file content mismatch: ' + str(path))
def save(state):
temporary = JOURNAL.with_suffix('.partial')
with temporary.open('x') as stream:
json.dump(state, stream, sort_keys=True, indent=2)
stream.write('\n')
stream.flush()
os.fsync(stream.fileno())
temporary.replace(JOURNAL)
def priority(name):
if name.startswith('debian/dists/') and name.endswith(('/InRelease', '/Release', '/Release.gpg')):
return 2
if name.startswith('debian/dists/'):
return 1
if name.startswith('site/'):
return 3
return 0
def main():
require(os.geteuid() == 0 and socket.gethostname().lower() == 'geranas', 'GERANAS root context required')
os.umask(0o077)
for path in (STAGE, ORIGIN):
path_safe(path)
manifest_path = STAGE / 'PUBLIC-OVERLAY-MANIFEST.json'
require(digest(manifest_path) == MANIFEST_SHA, 'Reviewed manifest changed')
manifest = json.loads(manifest_path.read_text())
require(manifest['source_commit'] == COMMIT and manifest['version'] == '0.3.3', 'Wrong release')
require(len(manifest['files']) == 70, 'Unexpected overlay inventory')
require(not JOURNAL.exists() and not BACKUP.exists(), 'Existing transaction; inspect before replay')
entries = {}
for name, record in manifest['files'].items():
p = PurePosixPath(name)
require(not p.is_absolute() and '..' not in p.parts and p.parts[0] in ('site','downloads','debian'), 'Unsafe manifest path')
source, destination = STAGE / 'overlay-files' / name, ORIGIN / name
path_safe(source)
path_safe(destination)
verify(source, record)
entries[name] = {'new': record, 'old': None}
if destination.exists():
require(destination.is_file(), 'Non-file destination')
s = destination.stat()
entries[name]['old'] = {'bytes': s.st_size, 'sha256': digest(destination),
'uid': s.st_uid, 'gid': s.st_gid, 'mode': stat.S_IMODE(s.st_mode)}
for name, record in manifest['preserve_required'].items():
verify(ORIGIN / name, record)
master = subprocess.run(['systemctl','show','framebridge-web.service','--property=MainPID','--value'],
check=True, capture_output=True, text=True).stdout.strip()
state = {'schema': 1, 'phase': 'PREPARED', 'source_commit': COMMIT, 'version': '0.3.3',
'manifest_sha256': MANIFEST_SHA, 'entries': entries, 'applied': [], 'master_pid': master,
'backup_directory': str(BACKUP), 'other_services_changed': False}
descriptor = os.open(JOURNAL, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
os.close(descriptor)
save(state)
BACKUP.mkdir(mode=0o700)
for name, entry in entries.items():
if entry['old'] is not None:
backup = BACKUP / name
backup.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
shutil.copy2(ORIGIN / name, backup)
verify(backup, entry['old'])
state['phase'] = 'BACKUP_VERIFIED'
save(state)
for name in sorted(entries, key=lambda n:(priority(n),n)):
source, destination = STAGE / 'overlay-files' / name, ORIGIN / name
old = entries[name]['old']
if old is not None:
verify(destination, old)
else:
require(not destination.exists(), 'Destination created since guard')
missing = []
parent = destination.parent
while not parent.exists():
missing.append(parent)
parent = parent.parent
for directory in reversed(missing):
directory.mkdir(mode=0o755)
directory.chmod(0o755)
fd, temporary = tempfile.mkstemp(prefix='.fb-release-', dir=destination.parent)
try:
with os.fdopen(fd, 'wb') as stream, source.open('rb') as inp:
shutil.copyfileobj(inp, stream)
stream.flush()
os.fsync(stream.fileno())
os.chmod(temporary, old['mode'] if old else 0o644)
if old:
os.chown(temporary, old['uid'], old['gid'])
verify(Path(temporary), entries[name]['new'])
os.replace(temporary, destination)
state['applied'].append(name)
state['phase'] = 'APPLYING'
save(state)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
for name, entry in entries.items():
verify(ORIGIN / name, entry['new'])
for name, record in manifest['preserve_required'].items():
verify(ORIGIN / name, record)
after = subprocess.run(['systemctl','show','framebridge-web.service','--property=MainPID','--value'],
check=True, capture_output=True, text=True).stdout.strip()
require(master == after, 'Unexpected web master change')
state.update(phase='COMPLETE', files_verified=70, dependency_sources_preserved=True,
web_master_preserved=True, old_release_files_removed=False)
save(state)
print(json.dumps({k:v for k,v in state.items() if k not in ('entries','applied')}, sort_keys=True))
if __name__ == '__main__':
main()
+21 -2
View File
@@ -52,6 +52,10 @@ def check_bundle(args, source):
refs = remote_refs()
require(refs.get("refs/heads/main") == args.old_commit, "Canonical main changed; review before publishing")
require("refs/tags/" + args.tag not in refs, "Release tag already exists; never overwrite")
if args.ops_branch:
require(re.fullmatch(r"ops/[a-z0-9][a-z0-9/-]*", args.ops_branch), "Invalid operations branch")
require("refs/heads/" + args.ops_branch not in refs, "Operations branch already exists; do not overwrite")
run(["git", "--git-dir", source, "merge-base", "--is-ancestor", args.commit, "refs/heads/" + args.ops_branch])
return refs
def record(path, state):
@@ -122,7 +126,7 @@ def publish(args):
"Bundle must be a root-owned reviewed regular file without shared write access")
root_directory(args.bundle.parent)
os.umask(0o077)
with tempfile.TemporaryDirectory(prefix="fb-gitea-push-", dir="/run") as tmp:
with tempfile.TemporaryDirectory(prefix="fb-gitea-push-", dir=args.bundle.parent) as tmp:
directory = Path(tmp)
source = directory / "source.git"
before = check_bundle(args, source)
@@ -165,20 +169,34 @@ def publish(args):
server, stop, thread, client = askpass_server(directory, token)
env = dict(os.environ, GIT_ASKPASS=str(client), GIT_TERMINAL_PROMPT="0", LC_ALL="C")
# No force flag. The normal receive-pack checks and Gitea hooks run.
refspecs = [args.commit + ":refs/heads/main", "refs/tags/" + args.tag]
if args.ops_branch:
refspecs.append("refs/heads/" + args.ops_branch + ":refs/heads/" + args.ops_branch)
run(["git", "--git-dir", source, "-c", "credential.helper=", "push", "--atomic",
REPOSITORY, args.commit + ":refs/heads/main", "refs/tags/" + args.tag], env=env, timeout=120)
REPOSITORY, *refspecs], env=env, timeout=120)
state["push_performed"] = True
state["phase"] = "PUSH_COMPLETED"
after = remote_refs()
require(after.get("refs/heads/main") == args.commit, "Main ref readback mismatch")
resolved_tag = after.get("refs/tags/" + args.tag + "^{}", after.get("refs/tags/" + args.tag))
require(resolved_tag == args.commit, "Release tag readback mismatch")
if args.ops_branch:
expected = run(["git", "--git-dir", source, "rev-parse", "refs/heads/" + args.ops_branch]).stdout.decode().strip()
require(after.get("refs/heads/" + args.ops_branch) == expected, "Operations ref readback mismatch")
state["ops_branch"] = args.ops_branch
state["ops_commit"] = expected
state["refs_verified"] = True
record(args.journal, state)
except Exception as error:
# Commands never contain the credential; do not print captured HTTP/Git output.
failure = f"{type(error).__name__}: {error}"
state["failure"] = failure
if isinstance(error, subprocess.CalledProcessError) and error.stderr:
detail = error.stderr.decode("utf-8", "replace")
if token:
detail = detail.replace(token, "[REDACTED]")
detail = detail.replace(base64.b64encode(("gerardo:" + token).encode()).decode(), "[REDACTED]")
state["safe_command_stderr"] = detail[-4000:]
finally:
if stop is not None:
stop.set()
@@ -213,6 +231,7 @@ def main():
parser.add_argument("--commit", required=True)
parser.add_argument("--old-commit", required=True)
parser.add_argument("--tag", required=True)
parser.add_argument("--ops-branch", help="Publish one new operations branch atomically with the release")
parser.add_argument("--journal", type=Path)
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()