Add first-run admin setup and independent password and API token rotation

This commit is contained in:
Mikei386
2026-09-28 15:02:41 +02:00
parent e86e540ae4
commit 9f3a13e45f
20 changed files with 651 additions and 76 deletions
+5 -3
View File
@@ -1,4 +1,4 @@
# WireGuard-Modul · Athena Deck 0.2
# WireGuard-Modul · Athena Deck 0.3
## Was eingebaut ist
@@ -24,7 +24,8 @@ Port-Veröffentlichung; eine Aussage „keinerlei Netzwerkänderung“ wäre daf
## Einrichtung in der Oberfläche
1. Auf dem Mac http://127.0.0.1:8108/#network öffnen.
2. Eigenes Deck-Passwort (mindestens 16 Zeichen) vergeben und **Modul auf Athena
2. Eigenes Deck-Passwort (mindestens 16 Zeichen) und separaten API-Token
(mindestens 32 Zeichen) vergeben, Token sichern und **Modul auf Athena
installieren** wählen. Installation läuft asynchron und meldet ihren Status.
3. Eigene IPv4-WireGuard-Conf importieren. **Einen eigenen Peer verwenden**, nicht
die Konfiguration des produktiven Athena-Gateways wiederverwenden.
@@ -67,6 +68,7 @@ HTTPS/Reverse-Proxy-Zertifikate sind noch nicht enthalten. Passwort wird mit
PBKDF2-SHA256 (600.000 Iterationen, individuellem Salt) gespeichert. Sitzungen sind
HttpOnly/SameSite=Strict, acht Stunden gültig und werden nach Neustart ungültig.
Ein Anmeldelimit begrenzt Versuche auf zehn pro Minute. Kein Standardpasswort.
Kennwort-/Tokenwechsel und Rechte sind in [Zugang und API](../ACCESS.md) beschrieben.
Im LAN-/Beides-Modus ist zusätzlich ein SSH-Tunnel zur Server-GUI möglich:
@@ -123,7 +125,7 @@ serverseitige Prüfung des tatsächlichen Zugangs gelten vor dem API-Aufruf.
|---|---|
| POST `/login` | `password` |
| GET `/network` | Status ohne Secrets |
| POST `/network/install` | `password` für neue Server-Instanz, nur Mac-Verwaltung |
| POST `/network/install` | `password`, `api_token` für neue Server-Instanz, nur angemeldete Mac-Verwaltung |
| POST `/network/import` | `config` als Dateiinhalt |
| POST `/network/connect` | `{}` |
| POST `/network/disconnect` | `{}` |
+12 -2
View File
@@ -17,6 +17,7 @@ import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from auth import normalize, validate_record
from network.config import parse_config, wireguard_text
from network.policy import Policy
@@ -87,7 +88,7 @@ class Proxy(BaseHTTPRequestHandler):
if not 0 <= length <= 32768 or len(self.path)>2048:
raise ValueError()
body = self.rfile.read(length) if length else None
headers = {name: self.headers[name] for name in ('Host', 'Origin', 'Content-Type', 'Cookie', 'X-Athena-Deck') if name in self.headers}
headers = {name: self.headers[name] for name in ('Host', 'Origin', 'Content-Type', 'Cookie', 'Authorization', 'X-Athena-Deck') if name in self.headers}
headers.update({'X-Deck-Proxy': PROXY_TOKEN, 'X-Deck-Ingress': self.server.ingress})
conn = http.client.HTTPConnection('127.0.0.1', 8108, timeout=30)
try:
@@ -188,6 +189,15 @@ class Agent:
# Ingress may only be asserted by the web child using the private proxy token.
if not secrets.compare_digest(str(data.get('proxy_token', '')), PROXY_TOKEN):
ingress = 'management'
if action == 'credentials-read':
return {'credentials': validate_record(normalize(json.loads((DATA/'auth.json').read_text())))}
if action == 'credentials-write':
current = validate_record(normalize(json.loads((DATA/'auth.json').read_text())))
if current['revision'] != data.get('expected_revision'):
raise ValueError('Zugangsdaten wurden inzwischen geändert. Bitte erneut anmelden.')
record = validate_record(data.get('credentials'))
save(DATA/'auth.json', record)
return {'saved':True}
if action == 'status':
return self.status(ingress)
if action == 'import':
@@ -260,7 +270,7 @@ def main():
if not auth.exists():
raise SystemExit('Authentication must be provisioned before container startup.')
env = os.environ.copy()
env.update(DECK_PROXY_TOKEN=PROXY_TOKEN, DECK_AUTH_JSON=auth.read_text(), DECK_NETWORK_SOCKET='1', DECK_LOCAL_HARDWARE='1')
env.update(DECK_PROXY_TOKEN=PROXY_TOKEN, DECK_AUTH_RPC='1', DECK_NETWORK_SOCKET='1', DECK_LOCAL_HARDWARE='1')
child = subprocess.Popen([sys.executable, '/app/server.py'], env=env, user=65534, group=65534, extra_groups=[], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
address = json.loads(command('ip', '-j', '-4', 'addr', 'show', 'dev', 'eth0'))[0]['addr_info'][0]['local']
lan = DeviceServer(address, 'eth0', 'lan')
+3 -3
View File
@@ -1,4 +1,5 @@
"""Local management via fixed SSH target or container-private Unix RPC."""
from auth import initial_record
import base64
import hashlib
import json
@@ -51,16 +52,15 @@ class NetworkClient:
self.cached_at = time.monotonic()
return dict(result, ingress=ingress, job=self.job)
def install(self, password):
def install(self, password, api_token=None):
if self.local:
raise ValueError('Die Server-Instanz ist bereits installiert.')
if not isinstance(password, str) or not 16 <= len(password) <= 256:
raise ValueError('Bitte ein eigenes Deck-Passwort mit mindestens 16 Zeichen setzen.')
auth = initial_record(password,api_token)
with self.lock:
if self.job and self.job['state'] == 'running':
raise ValueError('Installation läuft bereits.')
salt = secrets.token_bytes(16)
auth = dict(salt=salt.hex(), hash=hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 600000).hex())
self.job = dict(state='running', message='Eigener Deck-Container wird gebaut und gestartet. Das kann mehrere Minuten dauern.')
threading.Thread(target=self._install, args=(auth,), daemon=True).start()
return dict(job=self.job.copy())
+6 -8
View File
@@ -12,7 +12,7 @@ import sys
NAME = 'athena-deck-network'
BASE = Path('/opt/athena-deck')
FILES = {'server.py', 'demo.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'}
FILES = {'auth.py', 'access-ui.js', 'server.py', 'demo.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'}
def run(*args, **kwargs):
return subprocess.run(args, capture_output=True, timeout=600, **kwargs)
@@ -47,16 +47,14 @@ def main():
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(base64.b64decode(encoded, validate=True))
target.chmod(0o644)
result = run('docker', 'build', '-t', 'athena-deck-network:0.2', '-f', str(source/'network/Dockerfile'), str(source))
result = run('docker', 'build', '-t', 'athena-deck-network:0.3', '-f', str(source/'network/Dockerfile'), str(source))
if result.returncode:
raise ValueError('Container-Build fehlgeschlagen. Docker benötigt Zugriff auf die Paketquellen.')
state = BASE/'state'
state.mkdir(mode=0o700, exist_ok=True)
auth = payload['auth']
if set(auth) != {'salt', 'hash'} or len(auth['salt']) != 32 or len(auth['hash']) != 64:
raise ValueError('Ungültige Zugangsdaten.')
int(auth['salt'], 16)
int(auth['hash'], 16)
sys.path.insert(0,str(source))
from auth import validate_record
auth = validate_record(payload['auth'])
fd = os.open(state/'auth.json', os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, 'w') as stream:
json.dump(auth, stream)
@@ -65,7 +63,7 @@ def main():
'--security-opt', 'no-new-privileges:true', '--pids-limit', '128', '--memory', '256m', '--cpus', '0.5',
'--tmpfs', '/run:rw,nosuid,nodev,size=8m', '--tmpfs', '/tmp:rw,nosuid,nodev,size=8m',
'-v', str(state)+':/data:rw', '-e', 'DECK_LAN_IP='+lan,
'-p', '127.0.0.1:8110:8110', '-p', lan+':8110:8110', 'athena-deck-network:0.2')
'-p', '127.0.0.1:8110:8110', '-p', lan+':8110:8110', 'athena-deck-network:0.3')
if result.returncode:
# Auth remains protected for manual recovery; never delete an existing container.
raise ValueError('Containerstart fehlgeschlagen. Gesicherter Installationsstand liegt unter /opt/athena-deck.')
+31 -3
View File
@@ -35,14 +35,15 @@ v=json.load(sys.stdin)
c=http.client.HTTPConnection(v['host'],8110,timeout=5)
h={'Content-Type':'application/json','X-Athena-Deck':'1'}
if v.get('cookie'):h['Cookie']=v['cookie']
if v.get('token'):h['Authorization']='Bearer '+v['token']
if v.get('origin'):h['Origin']=v['origin']
c.request(v.get('method','GET'),v.get('path','/'),json.dumps(v['body']) if 'body' in v else None,h)
r=c.getresponse();b=r.read().decode()
print(json.dumps({'status':r.status,'body':b,'cookie':r.getheader('Set-Cookie')}))
'''
def http(host,path='/',method='GET',body=None,cookie=None):
value=dict(host=host,path=path,method=method,cookie=cookie)
def http(host,path='/',method='GET',body=None,cookie=None,token=None):
value=dict(host=host,path=path,method=method,cookie=cookie,token=token)
if body is not None:value['body']=body
return execute(PEER,HTTP,value)
@@ -56,7 +57,8 @@ def main():
state=tempfile.TemporaryDirectory(prefix='athena-deck-test-')
password=secrets.token_urlsafe(32)
salt=secrets.token_bytes(16)
auth=dict(salt=salt.hex(),hash=hashlib.pbkdf2_hmac('sha256',password.encode(),salt,600000).hex())
api_token=secrets.token_urlsafe(32)
auth=dict(version=1,password=dict(salt=salt.hex(),hash=hashlib.pbkdf2_hmac('sha256',password.encode(),salt,600000).hex()),revision=secrets.token_hex(16),api_token_hash=hashlib.sha256(api_token.encode()).hexdigest(),password_changed_at=time.time(),token_changed_at=time.time())
p=Path(state.name)/'auth.json';p.write_text(json.dumps(auth));p.chmod(0o600)
try:
run('docker','run','-d','--name',PEER,'--cap-drop','ALL','--cap-add','NET_ADMIN',IMAGE,'sleep','600')
@@ -78,6 +80,22 @@ def main():
cookie=login['cookie'].split(';')[0]
assert http(deckip,'/api/v1/status',cookie=cookie)['status']==200
print('PASS real LAN login and authenticated API',flush=True)
assert http(deckip,'/api/v1/status',token=api_token)['status']==200
assert http(deckip,'/api/v1/network',token=api_token)['status']==401
next_token=secrets.token_urlsafe(32)
assert http(deckip,'/api/v1/auth/token','POST',{'current_password':password,'new_token':next_token},cookie)['status']==200
assert http(deckip,'/api/v1/status',token=api_token)['status']==401
assert http(deckip,'/api/v1/status',token=next_token)['status']==200
assert http(deckip,'/api/v1/status',cookie=cookie)['status']==200
next_password=secrets.token_urlsafe(32)
assert http(deckip,'/api/v1/auth/password','POST',{'current_password':password,'new_password':next_password},cookie)['status']==200
assert http(deckip,'/api/v1/status',cookie=cookie)['status']==401
assert http(deckip,'/api/v1/login','POST',{'password':password})['status']==401
password=next_password
login=http(deckip,'/api/v1/login','POST',{'password':password})
assert login['status']==200
cookie=login['cookie'].split(';')[0]
print('PASS proxy forwards API token; rotations persist through privileged helper; old credentials rejected',flush=True)
# Keys stay in this Python process and stdin pipes; never printed.
private_a=run('docker','exec',PEER,'wg','genkey')
private_b=run('docker','exec',PEER,'wg','genkey')
@@ -132,6 +150,16 @@ cmd('ip','route','add','10.240.77.1/32','dev','peerwg0')
except Exception:time.sleep(.5)
assert status['mode']=='lan' and not status['pending']
print('PASS restart discards unconfirmed trial and restores confirmed mode',flush=True)
for _ in range(20):
try:
restored=http(deckip,'/api/v1/login','POST',{'password':password})
if restored['status']==200:break
except Exception:pass
time.sleep(.5)
else:raise RuntimeError('Changed password did not survive container restart')
assert http(deckip,'/api/v1/status',token=next_token)['status']==200
assert http(deckip,'/api/v1/status',token=api_token)['status']==401
print('PASS changed password and token survive container restart',flush=True)
after=run('docker','inspect','--format','{{.Id}} {{.State.StartedAt}}',*ids) if ids else ''
assert baseline==after
print('PASS production container start times unchanged',flush=True)