-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvesselstack-wizard.py
More file actions
executable file
·78 lines (69 loc) · 5.84 KB
/
Copy pathvesselstack-wizard.py
File metadata and controls
executable file
·78 lines (69 loc) · 5.84 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
#!/usr/bin/env python3
"""Loopback-only first-run configuration wizard for VesselStack."""
import argparse, json, os, platform, re, shlex, shutil, subprocess
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parent
WIZARD = ROOT / "wizard"
OUTPUT = ROOT / "vesselstack.env"
ALLOWED = re.compile(r"^[A-Za-z0-9 _.,:/@+()-]*$")
DEFAULTS = {
"BOAT_NAME":"My Boat","BOAT_TYPE":"Motor vessel","BOAT_MMSI":"","BOAT_CALLSIGN":"","BOAT_LOA_M":"","BOAT_BEAM_M":"","BOAT_TIMEZONE":"UTC","BOAT_UNITS":"metric",
"VESSELSTACK_USER":"boat","VESSELSTACK_UID":"1000","VESSELSTACK_GID":"1000","VESSELSTACK_ROOT":"/opt/vesselstack","VESSELSTACK_DATA":"/opt/vesselstack-data","VESSELSTACK_BACKUP":"/media/vesselstack-backup/backups",
"SIGNALK_MODE":"docker","SIGNALK_VERSION":"v2.27.0","SIGNALK_URL":"http://127.0.0.1:3000","SOCKETCAN_ENABLE":"false","SOCKETCAN_INTERFACE":"can0","SOCKETCAN_BITRATE":"250000",
"AIS_ENABLE":"false","AIS_IMAGE":"ghcr.io/jvde-github/ais-catcher:v0.70","AIS_DEVICE":"/dev/bus/usb","AIS_CATCHER_ARGS":"-q -N 8100 -S 5011","AIS_WEB_PORT":"8100","AIS_TCP_PORT":"5011",
"HOME_ASSISTANT_URL":"http://127.0.0.1:8123","HOME_ASSISTANT_TOKEN":"","INFLUXDB_URL":"http://127.0.0.1:8086","INFLUXDB_PORT":"8086","INFLUXDB_CONTAINER_NAME":"vesselstack-influxdb","INFLUXDB_ORG":"vesselstack","INFLUXDB_USERNAME":"boatadmin",
"INFLUXDB_RAW_BUCKET":"signalk","INFLUXDB_HISTORY_BUCKET":"signalk_1m","INFLUXDB_HOME_ASSISTANT_BUCKET":"homeassistant","INFLUXDB_AIS_BUCKET":"ais","INFLUXDB_PASSWORD":"GENERATE","INFLUXDB_TOKEN":"GENERATE","GRAFANA_ADMIN_PASSWORD":"GENERATE",
"MQTT_USERNAME":"homeassistant","MQTT_PASSWORD":"GENERATE","MQTT_PORT":"1883","BOAT_CHAT_PROVIDER":"local","BOAT_CHAT_HOST":"0.0.0.0","BOAT_CHAT_PORT":"8765","BOAT_CHAT_SETTINGS_TOKEN":"GENERATE","TELEGRAM_ENABLE":"false","TELEMETRY_INDEXER_ENABLE":"true",
"GRAFANA_PORT":"43000","HEIMDALL_PORT":"80","HEIMDALL_HTTPS_PORT":"443","PROMETHEUS_PORT":"9090","VESSELSTACK_UNTRUSTED_INTERFACE":"wlan0","VESSELSTACK_FIREWALL_ENABLE":"false","CONTROL_PANEL_HOST":"127.0.0.1","CONTROL_PANEL_PORT":"8780"
}
def compose_available():
if not shutil.which("docker"): return False
try:
return subprocess.run(["docker","compose","version"], timeout=4, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
except (OSError, subprocess.TimeoutExpired): return False
def system_report():
# Exclude container/VPN/bridge names: they add noise and can expose local
# topology in support screenshots. Only installation-relevant links belong
# in the browser inventory.
interfaces=sorted(p.name for p in Path("/sys/class/net").glob("*") if p.name.startswith(("eth","en","wlan","wl","can")))
return {"architecture":platform.machine(),"os":platform.platform(),"docker":bool(shutil.which("docker")),"compose":compose_available(),"interfaces":interfaces,"can0":"can0" in interfaces,"memory_gib":round(os.sysconf("SC_PAGE_SIZE")*os.sysconf("SC_PHYS_PAGES")/2**30,1),"output":str(OUTPUT)}
def validate(values):
unknown=set(values)-set(DEFAULTS)
if unknown: raise ValueError("Unknown settings: "+", ".join(sorted(unknown)))
result=DEFAULTS.copy()
for key,value in values.items():
if not isinstance(value,str) or "\n" in value or "\r" in value or not ALLOWED.fullmatch(value): raise ValueError(f"Invalid value for {key}")
result[key]=value
if result["SIGNALK_MODE"] not in {"existing","docker","native"}: raise ValueError("Invalid SignalK mode")
for key in ("SOCKETCAN_ENABLE","AIS_ENABLE","TELEGRAM_ENABLE","TELEMETRY_INDEXER_ENABLE","VESSELSTACK_FIREWALL_ENABLE"):
if result[key] not in {"true","false"}: raise ValueError(f"Invalid boolean for {key}")
return result
def render(values):
return "# Generated by the VesselStack installation wizard.\n"+"".join(f"{key}={shlex.quote(value)}\n" for key,value in validate(values).items())
class Handler(SimpleHTTPRequestHandler):
def __init__(self,*args,**kwargs): super().__init__(*args,directory=str(WIZARD),**kwargs)
def log_message(self,fmt,*args): pass
def end_headers(self):
self.send_header("Cache-Control","no-store"); self.send_header("X-Content-Type-Options","nosniff"); self.send_header("Content-Security-Policy","default-src 'self'; script-src 'self'; style-src 'self'"); super().end_headers()
def reply(self,status,payload):
body=json.dumps(payload).encode(); self.send_response(status); self.send_header("Content-Type","application/json"); self.send_header("Content-Length",str(len(body))); self.end_headers(); self.wfile.write(body)
def do_GET(self):
if self.path=="/api/system": return self.reply(200,system_report())
return super().do_GET()
def do_POST(self):
if self.path!="/api/save": return self.reply(404,{"error":"Not found"})
try:
size=int(self.headers.get("Content-Length","0"))
if size>65536: raise ValueError("Request too large")
values=json.loads(self.rfile.read(size)).get("values",{})
temp=OUTPUT.with_suffix(".env.tmp"); temp.write_text(render(values)); os.chmod(temp,0o600); os.replace(temp,OUTPUT)
self.reply(200,{"saved":str(OUTPUT),"next":f"sudo ./install.sh --config {OUTPUT.name} --dry-run"})
except (ValueError,json.JSONDecodeError,OSError) as exc: self.reply(400,{"error":str(exc)})
def main():
parser=argparse.ArgumentParser(description="VesselStack first-run wizard"); parser.add_argument("--host",default="127.0.0.1",choices=["127.0.0.1","::1"]); parser.add_argument("--port",type=int,default=8088); args=parser.parse_args()
server=ThreadingHTTPServer((args.host,args.port),Handler); print(f"VesselStack wizard: http://{args.host}:{args.port}"); print("Press Ctrl-C to stop.")
try: server.serve_forever()
except KeyboardInterrupt: pass
finally: server.server_close()
if __name__=="__main__": main()