diff --git a/hosts/heather/arion-compose.nix b/hosts/heather/arion-compose.nix index bdcaed5..906d270 100644 --- a/hosts/heather/arion-compose.nix +++ b/hosts/heather/arion-compose.nix @@ -29,6 +29,8 @@ 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 @@ -42,23 +44,33 @@ 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" '' @@ -74,7 +86,8 @@ let searchDescriptions: true hideInternetSearch: true hideVersion: true - # Static navigation only: no Docker API integration. + # Live container status: green dot for healthy containers (docker-socket-proxy). + statusStyle: dot # 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, @@ -92,8 +105,14 @@ let href: https://git.iscg.dev/mikl/nix-config ''; - # Explicitly empty to avoid stale/default Docker integration configuration. - homepageDockerYaml = pkgs.writeText "homepage-docker.yaml" "{}\n"; + # 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 + ''; in { @@ -176,6 +195,26 @@ 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, без остановки источника) @@ -381,6 +420,26 @@ 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 5bea89a..74d7e96 100644 --- a/hosts/heather/caddy.nix +++ b/hosts/heather/caddy.nix @@ -114,16 +114,17 @@ Allow: /" 200 encode zstd gzip ''; - # Retired control plane: keep TLS but never proxy to a daemon. + # paseo daemon endpoint для desktop/mobile-клиентов. + # Basic Auth здесь намеренно нет: мобильный клиент Paseo его не умеет. + # Доступ защищён встроенным PASEO_PASSWORD самого демона. "paseo.iscg.dev".extraConfig = '' - header Cache-Control "no-store" - respond "Paseo has been retired" 410 + reverse_proxy 127.0.0.1:6767 ''; - # Disabled pending isolation from the administrative account. + # JupyterLab: native login form (пароль хранится как bcrypt-хеш + # в /var/lib/jupyter-secrets/jupyter.env), наружу только HTTPS. "jupyter.iscg.dev".extraConfig = '' - header Cache-Control "no-store" - respond "Jupyter is temporarily disabled" 503 + reverse_proxy 127.0.0.1:8890 ''; # 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 8f22bf6..5699f61 100644 --- a/hosts/heather/default.nix +++ b/hosts/heather/default.nix @@ -20,11 +20,10 @@ ./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 is intentionally disabled until it has a separate service identity. - # Keep its venv, notebooks and external credentials untouched. + ./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 @@ -72,18 +71,6 @@ # 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). @@ -97,6 +84,7 @@ 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/minecraft-api.nix b/hosts/heather/minecraft-api.nix deleted file mode 100644 index 3f56d6c..0000000 --- a/hosts/heather/minecraft-api.nix +++ /dev/null @@ -1,60 +0,0 @@ -# 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" "10.42.0.0/16" ]; - 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 deleted file mode 100644 index 3595f56..0000000 --- a/hosts/heather/minecraft-api/server.py +++ /dev/null @@ -1,406 +0,0 @@ -#!/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() diff --git a/hosts/heather/paseo.nix b/hosts/heather/paseo.nix new file mode 100644 index 0000000..5ca3735 --- /dev/null +++ b/hosts/heather/paseo.nix @@ -0,0 +1,56 @@ +# 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 6a42035..e0a6f9b 100644 --- a/hosts/heather/tuwunel-admin.nix +++ b/hosts/heather/tuwunel-admin.nix @@ -1,8 +1,20 @@ -# 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. -{ ... }: +# 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, ... }: { systemd.services.tuwunel-admin = { description = "tuwunel-admin (Matrix admin web UI)"; @@ -11,34 +23,13 @@ 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; - 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; + # Бинарь статический, рут-запуск ок (биндит 127.0.0.1:8009). + User = "root"; + Environment = [ "HOME=/var/lib/tuwunel-admin" ]; }; }; }