Add isolated Debian installer with credential setup and guarded updates
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
FROM python:3.13-slim-bookworm
|
||||
WORKDIR /app
|
||||
COPY server.py auth.py collect_hardware.py demo.py /app/
|
||||
COPY index.html app.js studio.js style.css login.html login.js access-ui.js network-ui.js /app/
|
||||
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
|
||||
DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \
|
||||
DECK_REQUIRE_SETUP=1 DECK_LOCAL_HARDWARE=1 DECK_LOCATION=Server \
|
||||
DECK_NETWORK_MODE=disabled
|
||||
USER 65534:65534
|
||||
CMD ["python3", "server.py", "--port", "8108"]
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Standalone Debian installer. Explicit allowlist; never manages production services."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
LABEL = 'de.casaderoll.athena-deck.standalone'
|
||||
FILES = ['server.py','auth.py','collect_hardware.py','demo.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile']
|
||||
|
||||
|
||||
def run(*args, check=True, interactive=False):
|
||||
result = subprocess.run(args, text=True, capture_output=not interactive)
|
||||
if check and result.returncode:
|
||||
raise RuntimeError('Befehl fehlgeschlagen: '+args[0]+'. Vorhandene Dienste bleiben unangetastet.')
|
||||
return result
|
||||
|
||||
|
||||
def inspect(name):
|
||||
result = run('docker','inspect',name,check=False)
|
||||
return json.loads(result.stdout)[0] if result.returncode == 0 else None
|
||||
|
||||
|
||||
def owned(name, base):
|
||||
value = inspect(name)
|
||||
if value and value['Config'].get('Labels',{}).get(LABEL) != str(base):
|
||||
raise RuntimeError('Containername ist bereits anderweitig belegt. Keine Änderung ausgeführt.')
|
||||
return value
|
||||
|
||||
|
||||
def preflight(base, port):
|
||||
if sys.platform != 'linux' or os.geteuid() != 0:
|
||||
raise RuntimeError('Auf dem Debian-Zielserver mit sudo ausführen.')
|
||||
release = dict(line.split('=',1) for line in Path('/etc/os-release').read_text().splitlines() if '=' in line)
|
||||
if release.get('ID','').strip('"') != 'debian' or release.get('VERSION_ID','').strip('"') not in ('12','13'):
|
||||
raise RuntimeError('Unterstützt werden Debian 12 und 13.')
|
||||
if not shutil.which('docker'):
|
||||
raise RuntimeError('Docker fehlt. Zuerst nach der offiziellen Debian-Anleitung installieren: https://docs.docker.com/engine/install/debian/ . Der Installer verändert keine Host-Pakete.')
|
||||
version = run('docker','version','--format','{{.Server.Version}}').stdout.strip()
|
||||
if int(version.split('.')[0]) < 28:
|
||||
raise RuntimeError('Docker Engine >= 28 erforderlich. Kein automatisches Upgrade bestehender Docker-Installationen.')
|
||||
if not 1024 <= port <= 65535:
|
||||
raise RuntimeError('Port muss zwischen 1024 und 65535 liegen.')
|
||||
if not base.is_absolute() or base in (Path('/'),Path('/opt'),Path('/srv'),Path('/var/lib')) or base != base.resolve():
|
||||
raise RuntimeError('Eigenes absolutes Installationsverzeichnis ohne Symlinks erforderlich.')
|
||||
for name in FILES + ['deploy/provision.py']:
|
||||
if not (ROOT/name).is_file():
|
||||
raise RuntimeError('Unvollständiger Checkout: '+name)
|
||||
|
||||
|
||||
def free_port(port):
|
||||
with socket.socket() as sock:
|
||||
try:
|
||||
sock.bind(('127.0.0.1',port))
|
||||
except OSError:
|
||||
raise RuntimeError('Der gewünschte Loopback-Port ist bereits belegt.') from None
|
||||
|
||||
|
||||
def build():
|
||||
digest = hashlib.sha256()
|
||||
for name in FILES:
|
||||
digest.update(name.encode());digest.update((ROOT/name).read_bytes())
|
||||
tag = 'athena-deck-standalone:'+digest.hexdigest()[:16]
|
||||
print('Eigenes Deck-Image bauen (keine Modelle oder llama.cpp-Builds).',flush=True)
|
||||
result = run('docker','build','--label',LABEL+'=image','-t',tag,'-f',str(ROOT/'deploy/Dockerfile'),str(ROOT),interactive=True)
|
||||
return tag
|
||||
|
||||
|
||||
def write_manifest(base, config):
|
||||
temp=base/'installation.tmp'
|
||||
fd=os.open(temp,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600)
|
||||
with os.fdopen(fd,'w') as stream:
|
||||
json.dump(config,stream,indent=2)
|
||||
os.replace(temp,base/'installation.json')
|
||||
|
||||
|
||||
def load_manifest(base):
|
||||
config=json.loads((base/'installation.json').read_text())
|
||||
if config.get('owner') != LABEL or config.get('base') != str(base):
|
||||
raise RuntimeError('Kein gültiger eigener Installationsstand.')
|
||||
return config
|
||||
|
||||
|
||||
def launch(config):
|
||||
base=Path(config['base'])
|
||||
args=['docker','run','-d','--name',config['name'],'--label',LABEL+'='+str(base),
|
||||
'--restart','unless-stopped','--read-only','--cap-drop','ALL','--security-opt','no-new-privileges:true',
|
||||
'--pids-limit','128','--memory','512m','--cpus','1', '--tmpfs','/tmp:rw,nosuid,nodev,size=16m',
|
||||
'-v',str(base/'state')+':/var/lib/deck:rw','-e','DECK_ALLOWED_HOSTS=127.0.0.1:'+str(config['port'])+',localhost:'+str(config['port']),
|
||||
'-p','127.0.0.1:'+str(config['port'])+':8108',
|
||||
'--health-cmd', 'python3 -c "import urllib.request,json; assert json.load(urllib.request.urlopen(\'http://127.0.0.1:8108/api/v1/auth/status\',timeout=3))[\'initialized\']"',
|
||||
'--health-interval','30s','--health-timeout','5s','--health-retries','3']
|
||||
if config['gpu_telemetry']:
|
||||
args += ['--gpus','all','-e','NVIDIA_DRIVER_CAPABILITIES=utility']
|
||||
args.append(config['image'])
|
||||
run(*args)
|
||||
|
||||
|
||||
def ready(config):
|
||||
# Verify the new container itself as well as the published port.
|
||||
for _ in range(30):
|
||||
item=owned(config['name'],Path(config['base']))
|
||||
if item and item['State']['Running']:
|
||||
try:
|
||||
with urllib.request.urlopen('http://127.0.0.1:'+str(config['port'])+'/api/v1/auth/status',timeout=2) as r:
|
||||
if json.load(r).get('initialized') is True:
|
||||
return
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
time.sleep(1)
|
||||
raise RuntimeError('Neue Deck-Instanz wurde nicht bereit.')
|
||||
|
||||
|
||||
def provision(config):
|
||||
base=Path(config['base'])
|
||||
if (base/'state/auth.json').exists():
|
||||
raise RuntimeError('Zugangsdaten existieren bereits; keine Überschreibung.')
|
||||
os.chown(base/'bootstrap',65534,65534)
|
||||
run('docker','run','--rm','-it','--network','none','--user','65534:65534','--cap-drop','ALL',
|
||||
'--security-opt','no-new-privileges:true','--read-only','--tmpfs','/tmp:rw,nosuid,nodev,size=8m',
|
||||
'-v',str(base/'state')+':/var/lib/deck:rw','-v',str(base/'bootstrap')+':/bootstrap:rw',
|
||||
'-v',str(ROOT/'deploy/provision.py')+':/provision.py:ro','-e','PYTHONPATH=/app',config['image'],
|
||||
'python3','/provision.py',interactive=True)
|
||||
os.chown(base/'state/auth.json',65534,65534)
|
||||
|
||||
|
||||
def install(args):
|
||||
base=args.directory
|
||||
preflight(base,args.port)
|
||||
if owned(args.name,base):
|
||||
raise RuntimeError('Deck-Container existiert bereits. Für Updates --update verwenden.')
|
||||
if base.exists():
|
||||
raise RuntimeError('Installationsverzeichnis existiert bereits. Es wird nicht überschrieben.')
|
||||
free_port(args.port)
|
||||
parent=base.parent
|
||||
while not parent.exists():parent=parent.parent
|
||||
if shutil.disk_usage(parent).free < 2*1024**3:
|
||||
raise RuntimeError('Mindestens 2 GiB freier Speicher für Installation erforderlich.')
|
||||
if not sys.stdin.isatty():
|
||||
raise RuntimeError('Interaktives Terminal für Zugangseinrichtung benötigt (über SSH: ssh -t).')
|
||||
image=build()
|
||||
base.mkdir(parents=True,mode=0o700)
|
||||
for sub in ('state','models','backups','bootstrap'):
|
||||
(base/sub).mkdir(mode=0o700)
|
||||
os.chown(base/'state',65534,65534)
|
||||
config=dict(owner=LABEL,base=str(base),name=args.name,port=args.port,image=image,previous_image=None,gpu_telemetry=args.gpu_telemetry)
|
||||
write_manifest(base,config)
|
||||
provision(config)
|
||||
try:
|
||||
launch(config);ready(config)
|
||||
except Exception:
|
||||
item=owned(config['name'],base)
|
||||
if item:run('docker','rm','-f',config['name'])
|
||||
raise RuntimeError('Deck-Start fehlgeschlagen. Nur der neue Deck-Container wurde entfernt; Zugangsdaten und Installationsstand bleiben für --start erhalten.') from None
|
||||
print('Deck ist bereit: http://127.0.0.1:'+str(args.port))
|
||||
if (base/'bootstrap/api-token.txt').exists():
|
||||
print('Generierter API-Token: geschützte Datei '+str(base/'bootstrap/api-token.txt')+'. In Passwortmanager übernehmen und Datei danach entfernen.')
|
||||
|
||||
|
||||
def update(base, rollback=False):
|
||||
config=load_manifest(base)
|
||||
previous=owned(config['name'],base)
|
||||
if not previous:
|
||||
raise RuntimeError('Kein installierter Deck-Container vorhanden.')
|
||||
image=config.get('previous_image') if rollback else build()
|
||||
if not image:raise RuntimeError('Kein vorheriger Build gespeichert.')
|
||||
if image==config['image']:
|
||||
print('Dieser Quellstand ist bereits installiert.');return
|
||||
backup_name=config['name']+'-previous'
|
||||
if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.')
|
||||
stamp=str(time.time_ns())
|
||||
shutil.copytree(base/'state',base/'backups'/stamp)
|
||||
was_running=previous['State']['Running']
|
||||
if was_running:run('docker','stop','--time','15',config['name'])
|
||||
run('docker','rename',config['name'],backup_name)
|
||||
new=dict(config,image=image,previous_image=config['image'])
|
||||
try:
|
||||
launch(new);ready(new);write_manifest(base,new)
|
||||
except Exception:
|
||||
item=owned(config['name'],base)
|
||||
if item:run('docker','rm','-f',config['name'])
|
||||
run('docker','rename',backup_name,config['name'])
|
||||
if was_running:run('docker','start',config['name'])
|
||||
raise RuntimeError('Update fehlgeschlagen. Vorheriger Deck-Container wiederhergestellt.') from None
|
||||
run('docker','rm',backup_name)
|
||||
print('Nur Athena Deck wurde aktualisiert. Vorheriges Image bleibt für --rollback erhalten.')
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description='Athena Deck auf Debian 12/13 getrennt installieren. Kein WireGuard, keine Host-Paket- oder Treiberänderungen.')
|
||||
actions=parser.add_mutually_exclusive_group(required=True)
|
||||
for action in ('check','install','setup','start','stop','status','update','rollback'):
|
||||
actions.add_argument('--'+action,action='store_true')
|
||||
parser.add_argument('--directory',type=Path,default=Path('/opt/athena-deck-standalone'))
|
||||
parser.add_argument('--name',default='athena-deck-standalone')
|
||||
parser.add_argument('--port',type=int,default=8110)
|
||||
parser.add_argument('--gpu-telemetry',action='store_true',help='Vorhandenes NVIDIA Container Toolkit nur für Messwerte nutzen; installiert keine Treiber.')
|
||||
args=parser.parse_args()
|
||||
import re
|
||||
if not re.fullmatch(r'athena-deck-[a-z0-9-]{1,40}',args.name):
|
||||
parser.error('Name muss mit athena-deck- beginnen und nur Kleinbuchstaben, Zahlen und Bindestriche enthalten.')
|
||||
preflight(args.directory,args.port)
|
||||
if args.check:
|
||||
item=owned(args.name,args.directory)
|
||||
if not item:free_port(args.port)
|
||||
print('Vorprüfung erfolgreich. Keine Dateien, Images oder Dienste geändert.')
|
||||
elif args.install:
|
||||
install(args)
|
||||
else:
|
||||
config=load_manifest(args.directory)
|
||||
item=owned(config['name'],args.directory)
|
||||
if args.status:
|
||||
print('Deck: '+('running' if item and item['State']['Running'] else 'stopped / absent'))
|
||||
print('URL: http://127.0.0.1:'+str(config['port']))
|
||||
elif args.setup:
|
||||
provision(config)
|
||||
print('Zugang eingerichtet. Jetzt --start ausführen.')
|
||||
elif args.stop:
|
||||
if item:run('docker','stop','--time','15',config['name'])
|
||||
elif args.start:
|
||||
if not (args.directory/'state/auth.json').exists():
|
||||
raise RuntimeError('Zugangseinrichtung unvollständig. Siehe INSTALL.md zur Wiederaufnahme.')
|
||||
os.chown(args.directory/'state/auth.json',65534,65534)
|
||||
if item:run('docker','start',config['name'])
|
||||
else:launch(config)
|
||||
ready(config);print('Deck bereit.')
|
||||
elif args.update or args.rollback:update(args.directory,args.rollback)
|
||||
|
||||
if __name__=='__main__':
|
||||
try:main()
|
||||
except (RuntimeError,OSError,ValueError,KeyError) as exc:
|
||||
print('Installation/Betrieb abgebrochen: '+str(exc),file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Executed interactively inside a one-shot container. Never prints credentials."""
|
||||
import getpass
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import sys
|
||||
|
||||
from auth import CredentialStore
|
||||
|
||||
store = CredentialStore('/var/lib/deck/auth.json')
|
||||
if store.read() is not None:
|
||||
raise SystemExit('Zugangsdaten existieren bereits; keine Überschreibung.')
|
||||
if not sys.stdin.isatty():
|
||||
raise SystemExit('Interaktives Terminal benötigt. Keine Kennwörter als Argumente übergeben.')
|
||||
password = getpass.getpass('Neues Oberflächenkennwort (mindestens 16 Zeichen): ')
|
||||
repeat = getpass.getpass('Kennwort wiederholen: ')
|
||||
if password != repeat:
|
||||
raise SystemExit('Kennwörter stimmen nicht überein.')
|
||||
token = getpass.getpass('API-Token (mindestens 32 Zeichen; leer = sicher erzeugen): ')
|
||||
generated = not token
|
||||
if generated:
|
||||
token = 'ad_' + secrets.token_hex(32)
|
||||
if generated and Path('/bootstrap/api-token.txt').exists():
|
||||
raise SystemExit('Bootstrap-Datei existiert bereits; keine Überschreibung.')
|
||||
try:
|
||||
record = store.setup(password, token)
|
||||
except ValueError as exc:
|
||||
raise SystemExit(str(exc)) from None
|
||||
if generated:
|
||||
path = Path('/bootstrap/api-token.txt')
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd,'w') as stream:
|
||||
stream.write(token+'\n')
|
||||
print('API-Token wurde geschützt in der Bootstrap-Datei abgelegt (nicht ausgegeben).')
|
||||
print('Zugang eingerichtet. Kennwort und API-Token sind in der Anwendung nur als Prüfwerte gespeichert.')
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Explicit Linux/Docker smoke test: disposable container, no published ports."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
NAME='athena-deck-installer-smoke'
|
||||
IMAGE='athena-deck-installer-test:local'
|
||||
ROOT=Path(__file__).resolve().parent.parent
|
||||
|
||||
def run(*args,data=None,check=True):
|
||||
result=subprocess.run(args,input=data,text=True,capture_output=True,timeout=120)
|
||||
if check and result.returncode:raise RuntimeError('Smoke command failed (output withheld): '+args[0])
|
||||
return result.stdout.strip()
|
||||
|
||||
def main():
|
||||
if run('docker','ps','-aq','--filter','name=^/'+NAME+'$'):
|
||||
raise RuntimeError('Smoke container already exists; refusing takeover')
|
||||
ids=run('docker','ps','-aq').splitlines()
|
||||
before=run('docker','inspect','--format','{{.Id}} {{.State.StartedAt}}',*ids) if ids else ''
|
||||
with tempfile.TemporaryDirectory(prefix='deck-install-smoke-') as directory:
|
||||
base=Path(directory)
|
||||
for name in ('state','bootstrap'):
|
||||
(base/name).mkdir(mode=0o700);os.chown(base/name,65534,65534)
|
||||
password=secrets.token_urlsafe(32)
|
||||
script="""import json,sys,runpy
|
||||
from unittest.mock import patch
|
||||
p=json.load(sys.stdin)['password'];values=iter([p,p,''])
|
||||
with patch('getpass.getpass',side_effect=lambda _:next(values)),patch('sys.stdin.isatty',return_value=True):runpy.run_path('/provision.py',run_name='__main__')
|
||||
"""
|
||||
mounts=['-v',str(base/'state')+':/var/lib/deck','-v',str(base/'bootstrap')+':/bootstrap','-v',str(ROOT/'deploy/provision.py')+':/provision.py:ro']
|
||||
run('docker','run','--rm','-i','--network','none','--user','65534:65534','--cap-drop','ALL','--read-only','-e','PYTHONPATH=/app',*mounts,IMAGE,'python3','-c',script,data=json.dumps({'password':password}))
|
||||
token=(base/'bootstrap/api-token.txt').read_text().strip()
|
||||
assert (base/'bootstrap/api-token.txt').stat().st_mode&0o777==0o600
|
||||
assert token not in (base/'state/auth.json').read_text()
|
||||
print('PASS interactive provisioner with synthetic inputs; token protected, hashes persisted',flush=True)
|
||||
try:
|
||||
run('docker','run','-d','--name',NAME,'--network','none','--read-only','--cap-drop','ALL','--security-opt','no-new-privileges:true','--tmpfs','/tmp:rw,nosuid,nodev,size=8m','-v',str(base/'state')+':/var/lib/deck',IMAGE)
|
||||
probe="""import json,sys,urllib.request
|
||||
v=json.load(sys.stdin)
|
||||
req=urllib.request.Request('http://127.0.0.1:8108/api/v1/status',headers={'Authorization':'Bearer '+v['token']})
|
||||
with urllib.request.urlopen(req,timeout=3) as r:assert json.load(r)['location']=='Server'
|
||||
"""
|
||||
for _ in range(20):
|
||||
try:run('docker','exec','-i',NAME,'python3','-c',probe,data=json.dumps({'token':token}));break
|
||||
except RuntimeError:time.sleep(.5)
|
||||
else:raise RuntimeError('Server not ready')
|
||||
run('docker','stop',NAME);run('docker','start',NAME)
|
||||
for _ in range(20):
|
||||
try:run('docker','exec','-i',NAME,'python3','-c',probe,data=json.dumps({'token':token}));break
|
||||
except RuntimeError:time.sleep(.5)
|
||||
else:raise RuntimeError('Server not ready after restart')
|
||||
print('PASS unprivileged standalone server, API authentication and persistence after restart',flush=True)
|
||||
after=run('docker','inspect','--format','{{.Id}} {{.State.StartedAt}}',*ids) if ids else ''
|
||||
assert before==after
|
||||
print('PASS previously existing container start times unchanged',flush=True)
|
||||
finally:run('docker','rm','-f',NAME,check=False)
|
||||
|
||||
if __name__=='__main__':main()
|
||||
Reference in New Issue
Block a user