From 3e40846b5220b7fc4890fedf18bc1501e23c7ecc Mon Sep 17 00:00:00 2001 From: heather Date: Sun, 25 Oct 2026 16:26:45 +0000 Subject: [PATCH 1/3] Add isolated mesh-only Minecraft API with typed bot controls and mod review queue --- hosts/heather/default.nix | 1 + hosts/heather/minecraft-api.nix | 60 ++++ hosts/heather/minecraft-api/server.py | 406 ++++++++++++++++++++++++++ 3 files changed, 467 insertions(+) create mode 100644 hosts/heather/minecraft-api.nix create mode 100644 hosts/heather/minecraft-api/server.py diff --git a/hosts/heather/default.nix b/hosts/heather/default.nix index 5699f61..47f56b8 100644 --- a/hosts/heather/default.nix +++ b/hosts/heather/default.nix @@ -24,6 +24,7 @@ ./nick.nix # Nick timer prototype (systemd, localhost:8787) ./jupyter.nix # JupyterLab (systemd, localhost:8890, лимиты) ./astrnet.nix # AstrNet (Sol): two WireGuard legs, FRR/BGP, ingress guard + ./minecraft-api.nix # mesh-only typed Minecraft API, isolated DynamicUser ]; # Heather has 8 GiB RAM and runs several containers. Keep a 4 GiB diff --git a/hosts/heather/minecraft-api.nix b/hosts/heather/minecraft-api.nix new file mode 100644 index 0000000..2c2dbc3 --- /dev/null +++ b/hosts/heather/minecraft-api.nix @@ -0,0 +1,60 @@ +# Minecraft gateway only. Secrets stay outside the Nix store. +{ pkgs, ... }: +{ + systemd.tmpfiles.rules = [ + "d /var/lib/minecraft-api-root 0755 root root -" + ]; + systemd.services.minecraft-api = { + description = "Restricted Minecraft-only API for Sol (mesh only)"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" "astrnet-mesh0.service" "docker.service" ]; + wants = [ "network-online.target" ]; + startLimitIntervalSec = 0; + serviceConfig = { + Type = "simple"; + DynamicUser = true; + ExecStart = "${pkgs.python3}/bin/python3 -B ${./minecraft-api/server.py}"; + Restart = "on-failure"; + RestartSec = 10; + LoadCredential = "config.json:/var/lib/minecraft-api-secrets/config.json"; + StateDirectory = "minecraft-api"; + StateDirectoryMode = "0700"; + UMask = "0077"; + # Only the immutable Nix store, unit credentials and its own queue exist + # inside this root. No MC files, home, host /etc or Docker socket. + RootDirectory = "/var/lib/minecraft-api-root"; + MountAPIVFS = true; + BindReadOnlyPaths = [ "/nix/store" ]; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectProc = "invisible"; + ProcSubset = "pid"; + NoNewPrivileges = true; + CapabilityBoundingSet = ""; + AmbientCapabilities = ""; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + ProtectClock = true; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ "@system-service" ]; + RestrictAddressFamilies = [ "AF_INET" "AF_UNIX" ]; + IPAddressDeny = "any"; + IPAddressAllow = [ "10.0.0.0/24" "10.0.1.0/24" "10.0.2.0/24" "10.99.0.0/22" ]; + SocketBindDeny = "any"; + SocketBindAllow = "ipv4:tcp:25580"; + MemoryMax = "128M"; + CPUQuota = "25%"; + TasksMax = 20; + LimitNOFILE = 128; + }; + }; +} diff --git a/hosts/heather/minecraft-api/server.py b/hosts/heather/minecraft-api/server.py new file mode 100644 index 0000000..3595f56 --- /dev/null +++ b/hosts/heather/minecraft-api/server.py @@ -0,0 +1,406 @@ +#!/usr/bin/env python3 +"""Small typed Minecraft gateway. No shell, subprocess, downloads or game-file access.""" +import hmac +import json +import logging +import math +import os +import pathlib +import re +import socket +import socketserver +import struct +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import parse_qs, urlsplit + +BOTS = frozenset(('Terra', 'Luna', 'astri', 'milk')) +PLAYERS = BOTS | {'michaotics', 'SolAstrius'} +DIMENSIONS = {'overworld', 'the_nether', 'the_end'} +MAX_OUTPUT = 32768 +LOG = logging.getLogger('minecraft-api') + +class APIError(Exception): + def __init__(self, status, message): + self.status, self.message = status, message + +class Rcon: + def __init__(self, password): + self.password = password + + def run(self, command): + # Commands originate exclusively from Gateway's fixed templates. + with socket.create_connection(('10.0.0.9', 25575), timeout=3) as s: + s.settimeout(3) + deadline = time.monotonic() + 6 + def exact(n): + data = b'' + while len(data) < n: + if time.monotonic() > deadline: + raise OSError('RCON deadline') + part = s.recv(n - len(data)) + if not part: + raise OSError('RCON closed') + data += part + return data + def send(req, kind, text): + b = struct.pack(' MAX_OUTPUT: + raise APIError(502, 'Minecraft response exceeded the size limit') + chunks.append(data) + return b''.join(chunks).decode('utf-8', errors='replace') + +class Gateway: + def __init__(self, config, state_dir, rcon=None): + self.token = config['api_token'] + if not isinstance(self.token, str) or len(self.token) < 40: + raise ValueError('Strong API token required') + self.world = config['world'] + self.rcon = rcon or Rcon(config['rcon_password']) + self.state = pathlib.Path(state_dir) + self.requests_file = self.state / 'mod-requests.jsonl' + self.lock = threading.Lock() + self.budget_lock = threading.Lock() + self.budget, self.updated = 20.0, time.monotonic() + self.last = {} + + def authorize(self, authorization, origin): + # CLI/server clients only. No browser CORS, cookies or query tokens. + if origin is not None: + raise APIError(403, 'Browser-origin requests are disabled') + expected = 'Bearer ' + self.token + if not isinstance(authorization, str) or not hmac.compare_digest(authorization.encode(), expected.encode()): + raise APIError(401, 'Bearer token required') + + def limit(self): + with self.budget_lock: + now = time.monotonic() + self.budget = min(20.0, self.budget + now - self.updated) + self.updated = now + if self.budget < 1: + raise APIError(429, 'Rate limit: 60/minute, burst 20') + self.budget -= 1 + + def cooldown(self, key, seconds): + now = time.monotonic() + if now - self.last.get(key, -math.inf) < seconds: + raise APIError(429, 'Action cooldown active') + self.last[key] = now + + def command(self, text): + try: + return self.rcon.run(text) + except (OSError, ValueError, struct.error): + # Never return internal exceptions, config, passwords or file paths. + raise APIError(503, 'Minecraft RCON unavailable') from None + + @staticmethod + def exact_keys(obj, required, optional=()): + if not isinstance(obj, dict) or not set(required) <= obj.keys() or obj.keys() - set(required) - set(optional): + raise APIError(400, 'Missing or unexpected fields') + + @staticmethod + def query_geometry(q, radius=False): + Gateway.exact_keys(q, ('x', 'y', 'z'), ('dimension', 'radius') if radius else ('dimension',)) + d = q.get('dimension', 'overworld') + if d not in DIMENSIONS: + raise APIError(400, 'Unsupported dimension') + nums = [] + for key, lo, hi in [('x', -30000, 30000), ('y', -64, 319), ('z', -30000, 30000)]: + value = q[key] + if not isinstance(value, str) or not re.fullmatch(r'-?[0-9]{1,5}', value): + raise APIError(400, 'Coordinates must be bounded integers') + n = int(value) + if not lo <= n <= hi: + raise APIError(400, 'Coordinate out of bounds') + nums.append(n) + if radius: + r = q.get('radius', '16') + if not isinstance(r, str) or not re.fullmatch(r'[0-9]{1,2}', r) or not 1 <= int(r) <= 32: + raise APIError(400, 'Radius must be 1..32') + return d, *nums, int(r) + return d, *nums + + def dispatch(self, method, path, query, body): + self.limit() + if not self.lock.acquire(blocking=False): + raise APIError(429, 'Another Minecraft request is running') + try: + return self._dispatch(method, path, query, body) + finally: + self.lock.release() + + def _dispatch(self, method, path, q, body): + if method == 'GET': + if body: + raise APIError(400, 'GET body is not supported') + if path not in ('/v1/world/entities', '/v1/world/container') and q: + raise APIError(400, 'Unexpected query parameters') + if path == '/v1/capabilities': + return {'version': 1, 'bots': sorted(BOTS), 'players': sorted(PLAYERS), + 'read': ['status', 'world', 'player_state', 'bot_inventory', 'nearby_nonplayer_entities', 'container_items', 'mod_requests'], + 'write': ['kick_bot', 'teleport_bot_to_online_player', 'save_world', 'request_mod_review'], + 'denied': ['raw_console', 'op', 'auth', 'whitelist', 'block_edits', 'seed_change', 'mod_install', 'restart', 'files', 'shell'], + 'rate_limit': '60 requests/minute; burst20; one Minecraft request at a time', + 'limits': {'entities': 8, 'radius': 32, 'xz': 30000, 'response_bytes_per_command': MAX_OUTPUT}, + 'transport': 'HTTP bearer over WireGuard mesh only'} + if path == '/v1/status' or path == '/v1/players': + return {'minecraft': self.command('list')} + if path == '/v1/world': + return {'configured_world': self.world, 'day': self.command('time query day'), + 'daytime': self.command('time query daytime'), 'difficulty': self.command('difficulty'), + 'raining': self.command('execute if predicate {condition:"minecraft:weather_check",raining:true}'), + 'thundering': self.command('execute if predicate {condition:"minecraft:weather_check",thundering:true}')} + m = re.fullmatch(r'/v1/players/([A-Za-z0-9_]{1,16})', path) + if m: + name = m[1] + if name not in PLAYERS: + raise APIError(403, 'Player outside the allowlist') + return {'name': name, 'fields': {field: self.command(f'data get entity {name} {field}') + for field in ('Pos', 'Dimension', 'Health', 'foodLevel')}} + m = re.fullmatch(r'/v1/bots/([A-Za-z0-9_]{1,16})/inventory', path) + if m: + if m[1] not in BOTS: + raise APIError(403, 'Inventory access is restricted to bots') + return {'bot': m[1], 'snbt_response': self.command(f'data get entity {m[1]} Inventory')} + if path == '/v1/world/entities': + d, x, y, z, r = self.query_geometry(q, radius=True) + self.cooldown('entities', 3) + cmd = f'execute in minecraft:{d} positioned {x} {y} {z} as @e[type=!minecraft:player,distance=..{r},sort=nearest,limit=8] run data get entity @s' + return {'dimension': d, 'center': [x, y, z], 'radius': r, 'max_entities': 8, + 'loaded_only': True, 'snbt_response': self.command(cmd)} + if path == '/v1/world/container': + d, x, y, z = self.query_geometry(q) + return {'dimension': d, 'position': [x, y, z], 'loaded_only': True, + 'snbt_response': self.command(f'execute in minecraft:{d} run data get block {x} {y} {z} Items')} + if path == '/v1/mod-requests': + return {'requests': self.read_requests(), 'installation': 'Manual owner approval only; nothing is downloaded or installed'} + raise APIError(404, 'Unknown endpoint') + if method != 'POST': + raise APIError(405, 'Only GET and POST are supported') + if q: + raise APIError(400, 'POST query parameters are not supported') + if path == '/v1/save': + self.exact_keys(body, ()) + self.cooldown('save', 300) + return {'minecraft': self.command('save-all flush'), 'note': 'Saves the world, does not create a backup archive'} + if path == '/v1/bots/kick': + self.exact_keys(body, ('bot',)) + bot = body['bot'] + if not isinstance(bot, str) or bot not in BOTS: + raise APIError(403, 'Only allowed bots can be kicked') + self.cooldown('kick:' + bot, 10) + return {'minecraft': self.command(f'kick {bot} Disconnected through the Minecraft API')} + if path == '/v1/bots/teleport': + self.exact_keys(body, ('bot', 'to_player')) + bot, target = body['bot'], body['to_player'] + if not isinstance(bot, str) or bot not in BOTS: + raise APIError(403, 'Only allowed bots can be teleported') + if not isinstance(target, str) or target not in PLAYERS: + raise APIError(403, 'Target must be an allowlisted online player') + self.cooldown('teleport:' + bot, 30) + # Both arguments are literal validated names, never selectors or coordinates. + return {'minecraft': self.command(f'teleport {bot} {target}')} + if path == '/v1/mod-requests': + self.exact_keys(body, ('project', 'version_id')) + project, version = body['project'], body['version_id'] + if not isinstance(project, str) or not re.fullmatch(r'[A-Za-z0-9_-]{1,64}', project): + raise APIError(400, 'Expected Modrinth project ID or slug, not a URL') + if not isinstance(version, str) or not re.fullmatch(r'[A-Za-z0-9]{8}', version): + raise APIError(400, 'Expected exact eight-character Modrinth version ID') + old = self.read_requests() + for item in old: + if item['project'] == project and item['version_id'] == version: + return item + if len(old) >= 100: + raise APIError(409, 'Review queue is full; contact the owner') + self.cooldown('mod-request', 60) + item = {'id': len(old) + 1, 'project': project, 'version_id': version, + 'requested_at': int(time.time()), 'status': 'pending_owner_review', + 'downloaded': False, 'installed': False} + with self.requests_file.open('a', encoding='utf-8') as f: + f.write(json.dumps(item) + '\n') + f.flush() + os.fsync(f.fileno()) + LOG.info('mod_request id=%s project=%s version=%s', item['id'], project, version) + return item + raise APIError(404, 'Unknown endpoint') + + def read_requests(self): + if not self.requests_file.exists(): + return [] + return [json.loads(line) for line in self.requests_file.read_text().splitlines()][:100] + + +def unique_object(pairs): + result = {} + for k, v in pairs: + if k in result: + raise ValueError('Duplicate JSON field') + result[k] = v + return result + +class Handler(BaseHTTPRequestHandler): + server_version = 'MinecraftGateway/1' + sys_version = '' + protocol_version = 'HTTP/1.0' + + def setup(self): + super().setup() + self.connection.settimeout(4) + + def log_message(self, *args): + # The standard logger can echo an attacker-controlled request line. + pass + + def send_json(self, status, value): + data = json.dumps(value, ensure_ascii=True, allow_nan=False).encode() + self.send_response(status) + self.send_header('Content-Type', 'application/json; charset=utf-8') + self.send_header('Content-Length', str(len(data))) + self.send_header('Cache-Control', 'no-store') + self.send_header('X-Content-Type-Options', 'nosniff') + self.send_header('Connection', 'close') + if status == 401: + self.send_header('WWW-Authenticate', 'Bearer') + if status == 429: + self.send_header('Retry-After', '10') + self.end_headers() + self.wfile.write(data) + + def handle_api(self): + started = time.monotonic() + status, route = 500, 'invalid' + try: + g = self.server.gateway + if len(self.headers.get_all('Authorization', [])) != 1: + raise APIError(401, 'One Bearer header required') + g.authorize(self.headers.get('Authorization'), self.headers.get('Origin')) + if len(self.path) > 512 or '%' in self.path or '\\' in self.path: + raise APIError(400, 'Invalid request target') + u = urlsplit(self.path) + if u.scheme or u.netloc or u.fragment or not u.path.startswith('/v1/'): + raise APIError(400, 'Expected a relative /v1/ endpoint') + route = u.path if re.fullmatch(r'/[A-Za-z0-9_/-]{1,100}', u.path) else 'invalid' + if route == 'invalid': + raise APIError(400, 'Invalid endpoint path') + if self.headers.get('Transfer-Encoding'): + raise APIError(400, 'Chunked requests are unsupported') + lengths = self.headers.get_all('Content-Length', []) + if len(lengths) > 1: + raise APIError(400, 'Multiple Content-Length headers') + raw_length = lengths[0] if lengths else '0' + if not re.fullmatch(r'[0-9]{1,5}', raw_length) or int(raw_length) > 2048: + raise APIError(413, 'Body too large') + length = int(raw_length) + if self.command != 'POST' and length: + raise APIError(400, 'Unexpected body') + body = {} + if self.command == 'POST': + if self.headers.get('Content-Type', '').split(';')[0].strip() != 'application/json': + raise APIError(415, 'Content-Type must be application/json') + raw = self.rfile.read(length) + if len(raw) != length: + raise APIError(400, 'Incomplete body') + body = json.loads(raw, object_pairs_hook=unique_object) + if not isinstance(body, dict): + raise APIError(400, 'Expected a JSON object') + pairs = parse_qs(u.query, keep_blank_values=True, strict_parsing=True, max_num_fields=8) if u.query else {} + if any(len(v) != 1 for v in pairs.values()): + raise APIError(400, 'Duplicate query parameters') + q = {k: v[0] for k, v in pairs.items()} + result = g.dispatch(self.command, u.path, q, body) + status = 200 + self.send_json(status, result) + except APIError as e: + status = e.status + self.send_json(status, {'error': e.message}) + except (ValueError, UnicodeError, RecursionError): + status = 400 + self.send_json(status, {'error': 'Malformed input'}) + except (OSError, TimeoutError): + status = 503 + try: + self.send_json(status, {'error': 'Request unavailable or timed out'}) + except OSError: + pass + except Exception: + status = 500 + LOG.error('Internal request failure') + self.send_json(status, {'error': 'Internal error'}) + finally: + LOG.info('request method=%s route=%s status=%d ms=%d peer=%s', + self.command, route, status, int((time.monotonic()-started)*1000), self.client_address[0]) + self.close_connection = True + + do_GET = handle_api + do_POST = handle_api + do_PUT = handle_api + do_DELETE = handle_api + do_PATCH = handle_api + do_HEAD = handle_api + do_OPTIONS = handle_api + +class Server(socketserver.ThreadingMixIn, HTTPServer): + daemon_threads = True + request_queue_size = 8 + def __init__(self, address, gateway): + self.gateway = gateway + self.slots = threading.BoundedSemaphore(8) + super().__init__(address, Handler) + + def process_request(self, request, client_address): + if not self.slots.acquire(blocking=False): + self.shutdown_request(request) + return + try: + super().process_request(request, client_address) + except BaseException: + self.slots.release() + raise + + def process_request_thread(self, request, client_address): + try: + super().process_request_thread(request, client_address) + finally: + self.slots.release() + +if __name__ == '__main__': + logging.basicConfig(level=logging.INFO, format='%(message)s') + credentials = pathlib.Path(os.environ['CREDENTIALS_DIRECTORY']) / 'config.json' + config = json.loads(credentials.read_text()) + gateway = Gateway(config, os.environ['STATE_DIRECTORY']) + LOG.info('Starting Minecraft-only gateway on mesh 10.0.0.9:25580') + Server(('10.0.0.9', 25580), gateway).serve_forever() From 8c4840a0d3c996be39d5f466c4f3cbfc86601dd9 Mon Sep 17 00:00:00 2001 From: heather Date: Sun, 25 Oct 2026 16:35:56 +0000 Subject: [PATCH 2/3] Allow Sol agent subnet 10.42/16 in Minecraft API cgroup filter --- hosts/heather/minecraft-api.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hosts/heather/minecraft-api.nix b/hosts/heather/minecraft-api.nix index 2c2dbc3..3f56d6c 100644 --- a/hosts/heather/minecraft-api.nix +++ b/hosts/heather/minecraft-api.nix @@ -48,7 +48,7 @@ SystemCallFilter = [ "@system-service" ]; RestrictAddressFamilies = [ "AF_INET" "AF_UNIX" ]; IPAddressDeny = "any"; - IPAddressAllow = [ "10.0.0.0/24" "10.0.1.0/24" "10.0.2.0/24" "10.99.0.0/22" ]; + IPAddressAllow = [ "10.0.0.0/24" "10.0.1.0/24" "10.0.2.0/24" "10.99.0.0/22" "10.42.0.0/16" ]; SocketBindDeny = "any"; SocketBindAllow = "ipv4:tcp:25580"; MemoryMax = "128M"; From 6e2ba02b9ac5ee65d91fee0109334c634ea355a5 Mon Sep 17 00:00:00 2001 From: heather Date: Sun, 25 Oct 2026 22:56:01 +0000 Subject: [PATCH 3/3] Harden Heather: retire remote agents and proxy, disable Jupyter, sandbox Matrix admin --- hosts/heather/arion-compose.nix | 65 ++------------------------------- hosts/heather/caddy.nix | 13 +++---- hosts/heather/default.nix | 17 +++++++-- hosts/heather/paseo.nix | 56 ---------------------------- hosts/heather/tuwunel-admin.nix | 51 +++++++++++++++----------- 5 files changed, 53 insertions(+), 149 deletions(-) delete mode 100644 hosts/heather/paseo.nix diff --git a/hosts/heather/arion-compose.nix b/hosts/heather/arion-compose.nix index 906d270..bdcaed5 100644 --- a/hosts/heather/arion-compose.nix +++ b/hosts/heather/arion-compose.nix @@ -29,8 +29,6 @@ let icon: forgejo.png href: https://git.iscg.dev description: Git forge (ssh :2222) - server: heather - container: forgejo - Syncthing: icon: syncthing.png href: https://sync.iscg.dev @@ -44,33 +42,23 @@ let icon: matrix.png href: https://iscg.dev description: Matrix homeserver (iscg.dev) - server: heather - container: tuwunel - Cinny: icon: cinny.png href: https://chat.iscg.dev description: Matrix web client - server: heather - container: cinny - Jitsi Meet: icon: jitsi-meet.png href: https://jitsi.iscg.dev description: Video conferencing - server: heather - container: jitsi-web - Productivity: - Silverbullet: icon: silverbullet.png href: https://note.iscg.dev description: Notes wiki (herbarium) - server: heather - container: silverbullet - Teable: icon: /images/teable.png href: https://teable.iscg.dev description: No-code database - server: heather - container: teable ''; homepageSettingsYaml = pkgs.writeText "homepage-settings.yaml" '' @@ -86,8 +74,7 @@ let searchDescriptions: true hideInternetSearch: true hideVersion: true - # Live container status: green dot for healthy containers (docker-socket-proxy). - statusStyle: dot + # Static navigation only: no Docker API integration. # Background photo (mounted ro at /app/public/images/ from /var/lib/homepage-images). # cardBlur = frosted-glass / matte semi-transparent service & bookmark cards. # NB: cardBlur incompatible with background blur/saturate/brightness filters, @@ -105,14 +92,8 @@ let href: https://git.iscg.dev/mikl/nix-config ''; - # Docker integration: live container status (green dot / UP) per service. - # docker-socket-proxy exposes read-only /containers/json. homepage talks to it - # over the compose network (service name `dockerproxy`), no root on docker.sock. - homepageDockerYaml = pkgs.writeText "homepage-docker.yaml" '' - heather: - host: dockerproxy - port: 2375 - ''; + # Explicitly empty to avoid stale/default Docker integration configuration. + homepageDockerYaml = pkgs.writeText "homepage-docker.yaml" "{}\n"; in { @@ -195,26 +176,6 @@ in service.ports = [ "127.0.0.1:8080:80" ]; }; - # === Xray (VLESS + Reality, :8443) === - # Прокси-сервер для обхода блокировок. Не относится к iscg.dev-сервисам: - # НЕ за Caddy, публичный порт 8443 напрямую. Reality маскируется под - # www.microsoft.com (чужой TLS, своего домена/cert не нужно). - # Конфиг с UUID + Reality privateKey в /var/lib/xray-secrets/config.json - # (вне git, root:docker 0640). Клиент: v2RayTun / v2rayN / etc. - # Секреты в pass iscg.dev/xray/{uuid,reality-private-key,reality-public-key}. - xray = { - service.image = "ghcr.io/xtls/xray-core:latest"; - service.container_name = "xray"; - service.restart = "unless-stopped"; - # Образ по умолчанию читает -confdir /usr/local/etc/xray/ (split-конфиги). - # Переопределяем на единый config.json с VLESS+Reality. - service.command = [ "-config" "/etc/xray/config.json" ]; - service.volumes = [ - "/var/lib/xray-secrets/config.json:/etc/xray/config.json:ro" - ]; - service.ports = [ "8443:8443" ]; - }; - # === Teable (teable.iscg.dev) === # Стек: app + postgres + redis. Данные перенесены с iscg-dev (2026-07-12): # - postgres восстановлен из pg_dump (18M, consistent, без остановки источника) @@ -420,26 +381,6 @@ in }; }; - # === Docker socket proxy (read-only, for homepage live container status) === - # tecnativa/docker-socket-proxy: whitelisted read-only subset of Docker API. - # CONTAINERS=1 → only /containers/json + /containers/{id}/json. POST=0 → - # no mutations. homepage connects over compose network (host: dockerproxy). - # Avoids mounting docker.sock directly into homepage (which would need root). - dockerproxy = { - service.image = "ghcr.io/tecnativa/docker-socket-proxy:latest"; - service.container_name = "dockerproxy"; - service.restart = "unless-stopped"; - service.volumes = [ - "/var/run/docker.sock:/var/run/docker.sock:ro" - ]; - service.environment = { - CONTAINERS = "1"; - POST = "0"; - SERVICES = "1"; - TASKS = "1"; - }; - }; - # === Homepage (home.iscg.dev) — dashboard со всеми сервисами === # gethomepage/homepage: статичный список ссылок на все сервисы heather. # Конфиги (services/settings/bookmarks.yaml) генерятся через pkgs.writeText diff --git a/hosts/heather/caddy.nix b/hosts/heather/caddy.nix index 74d7e96..5bea89a 100644 --- a/hosts/heather/caddy.nix +++ b/hosts/heather/caddy.nix @@ -114,17 +114,16 @@ Allow: /" 200 encode zstd gzip ''; - # paseo daemon endpoint для desktop/mobile-клиентов. - # Basic Auth здесь намеренно нет: мобильный клиент Paseo его не умеет. - # Доступ защищён встроенным PASEO_PASSWORD самого демона. + # Retired control plane: keep TLS but never proxy to a daemon. "paseo.iscg.dev".extraConfig = '' - reverse_proxy 127.0.0.1:6767 + header Cache-Control "no-store" + respond "Paseo has been retired" 410 ''; - # JupyterLab: native login form (пароль хранится как bcrypt-хеш - # в /var/lib/jupyter-secrets/jupyter.env), наружу только HTTPS. + # Disabled pending isolation from the administrative account. "jupyter.iscg.dev".extraConfig = '' - reverse_proxy 127.0.0.1:8890 + header Cache-Control "no-store" + respond "Jupyter is temporarily disabled" 503 ''; # Nick timer prototype. Public timer pages; creation has no auth for now. diff --git a/hosts/heather/default.nix b/hosts/heather/default.nix index 47f56b8..8f22bf6 100644 --- a/hosts/heather/default.nix +++ b/hosts/heather/default.nix @@ -20,9 +20,9 @@ ./caddy.nix # edge proxy, TLS, роутинг по доменам ./syncthing.nix # sync + relay ./tuwunel-admin.nix # matrix admin web UI (systemd, статический бинарник) - ./paseo.nix # paseo daemon (web UI для CLI-агентов, systemd + npm global) ./nick.nix # Nick timer prototype (systemd, localhost:8787) - ./jupyter.nix # JupyterLab (systemd, localhost:8890, лимиты) + # Jupyter is intentionally disabled until it has a separate service identity. + # Keep its venv, notebooks and external credentials untouched. ./astrnet.nix # AstrNet (Sol): two WireGuard legs, FRR/BGP, ingress guard ./minecraft-api.nix # mesh-only typed Minecraft API, isolated DynamicUser ]; @@ -72,6 +72,18 @@ # explicitly with `arion --prebuilt-file up -d --no-deps SERVICE`. systemd.services.heather.restartIfChanged = false; + # The public dashboard is navigation only, never a Docker administration client. + assertions = [ + { + assertion = let + services = config.virtualisation.arion.projects.heather.settings.services; + in !(services ? dockerproxy) && lib.all + (service: lib.all (mount: !(lib.hasInfix "docker.sock" mount)) service.service.volumes) + (lib.attrValues services); + message = "Heather web stack must not expose Docker socket access or a dockerproxy service."; + } + ]; + networking = { hostName = "heather"; # Firewall выключен в ./image.nix (полагаемся на YC Security Groups). @@ -85,7 +97,6 @@ 22067 # strelaysrv (relay) 22070 # strelaysrv (status) 3478 # coturn TURN (tcp+udp) - 8443 # xray VLESS+Reality (публичный прокси) ]; firewall.allowedUDPPorts = [ 22000 # syncthing sync (QUIC) diff --git a/hosts/heather/paseo.nix b/hosts/heather/paseo.nix deleted file mode 100644 index 5ca3735..0000000 --- a/hosts/heather/paseo.nix +++ /dev/null @@ -1,56 +0,0 @@ -# heather — paseo daemon (self-hosted web UI для CLI-агентов: pi, codex и др.). -# -# https://github.com/getpaseo/paseo, npm-пакет @getpaseo/cli -# Daemon управляет агентами как дочерними процессами (provider = CLI на хосте, -# со своими кредами). Слушает 127.0.0.1:6767 → Caddy paseo.iscg.dev терминирует -# TLS. Этот hostname предназначен для desktop/mobile-клиентов: bundled web UI -# выключен, а Caddy Basic Auth намеренно не используется — мобильный клиент не -# умеет проходить дополнительный Basic Auth слой. -# -# Auth: встроенная парольная auth демона (bcrypt, Bearer header + WS subprotocol, -# без пароля — 401). Пароль: PASEO_PASSWORD из EnvironmentFile -# /var/lib/paseo-secrets/paseo.env (mikl:users 600), plaintext в -# pass iscg.dev/paseo/daemon. -# -# Конфиг демона: ~/.paseo/config.json (hostnames: paseo.iscg.dev — DNS rebinding -# protection, webUi disabled, relay off, dictation/voice off). Хост-allowlist -# проверен: чужой Host → 403. -# -# Установка @getpaseo/cli — ручная (npm global в ~/.npm-global, как pi-web): -# npm i -g @getpaseo/cli # node-pty собирается без тулчейна (prebuilds) -# Провайдеры: pi уже стоит (~/.local/bin/pi). - -{ config, pkgs, lib, ... }: -{ - systemd.services.paseo = { - description = "paseo daemon (self-hosted web UI for CLI coding agents)"; - after = [ "network-online.target" ]; - wants = [ "network-online.target" ]; - wantedBy = [ "multi-user.target" ]; - - # PATH: nodejs (node/npm) + ~/.npm-global/bin (paseo) + ~/.local/bin (pi). - # Для package/path NixOS добавляет /bin сам: каталоги должны быть - # уровнями выше фактических ~/.npm-global/bin и ~/.local/bin. - # Paseo запускает `ps` при управлении дочерними agent-процессами. - # Tool runner также ищет `sh` через PATH; на NixOS нельзя полагаться - # на FHS /bin/sh, поэтому добавляем bashInteractive явно. - path = [ "/run/current-system/sw" pkgs.nodejs pkgs.git pkgs.procps pkgs.bashInteractive "/home/mikl/.npm-global" "/home/mikl/.local" ]; - - environment = { - PASEO_HOME = "/home/mikl/.paseo"; - HOME = "/home/mikl"; - }; - - serviceConfig = { - Type = "simple"; - User = "mikl"; - Group = "users"; - ExecStart = "/home/mikl/.npm-global/bin/paseo daemon start --foreground --no-web-ui --no-relay"; - EnvironmentFile = "/var/lib/paseo-secrets/paseo.env"; - WorkingDirectory = "/home/mikl"; - Restart = "always"; - RestartSec = 5; - StartLimitIntervalSec = 0; - }; - }; -} diff --git a/hosts/heather/tuwunel-admin.nix b/hosts/heather/tuwunel-admin.nix index e0a6f9b..6a42035 100644 --- a/hosts/heather/tuwunel-admin.nix +++ b/hosts/heather/tuwunel-admin.nix @@ -1,20 +1,8 @@ -# heather — tuwunel-admin (web admin UI for tuwunel Matrix homeserver). -# -# https://github.com/knadh/tuwunel-admin (Rust, axum, self-contained static binary). -# Логинится matrix-пользователем на homeserver https://iscg.dev (см. [matrix] -# в config.toml). Чтобы UI мог выполнять admin-команды, залогиненный юзер -# должен быть server admin tuwunel (первый зарегистрированный = admin room member). -# -# Слушает 127.0.0.1:8009 → Caddy admin.iscg.dev терминирует TLS (см. caddy.nix). -# Конфиг: /var/lib/tuwunel-admin/config.toml (вне git, root:root 0644). -# -# Бинарь: публичного docker-образа НЕТ. Это статический x86_64 бинарник v0.0.0 -# (скопирован с iscg-dev-jul11 2026-07-13), лежит ВНЕ nix-store: -# /var/lib/tuwunel-admin/tuwunel-admin -# При пересоздании heather с нуля — перескопировать с jul11 или скачать релиз -# https://github.com/knadh/tuwunel-admin/releases (v0.1.0+). - -{ config, pkgs, lib, ... }: +# Public Matrix admin UI: a separate unprivileged, sandboxed identity. +# The root-owned binary and non-secret config remain read-only outside Nix. +# StateDirectory is deliberately DIFFERENT from the executable directory: +# systemd must never make the binary/config writable by the service user. +{ ... }: { systemd.services.tuwunel-admin = { description = "tuwunel-admin (Matrix admin web UI)"; @@ -23,13 +11,34 @@ wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "simple"; + DynamicUser = true; + User = "tuwunel-admin"; + StateDirectory = "tuwunel-admin-state"; + StateDirectoryMode = "0700"; + WorkingDirectory = "/var/lib/tuwunel-admin-state"; + Environment = [ "HOME=/var/lib/tuwunel-admin-state" ]; ExecStart = "/var/lib/tuwunel-admin/tuwunel-admin --config /var/lib/tuwunel-admin/config.toml"; - WorkingDirectory = "/var/lib/tuwunel-admin"; Restart = "on-failure"; RestartSec = 5; - # Бинарь статический, рут-запуск ок (биндит 127.0.0.1:8009). - User = "root"; - Environment = [ "HOME=/var/lib/tuwunel-admin" ]; + UMask = "0077"; + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + ProtectClock = true; + RestrictSUIDSGID = true; + RestrictNamespaces = true; + LockPersonality = true; + CapabilityBoundingSet = ""; + AmbientCapabilities = ""; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; + MemoryMax = "256M"; + TasksMax = 128; }; }; }