Add first-run admin setup and independent password and API token rotation
This commit is contained in:
+5
-3
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user