Compare commits

..

3 commits

7 changed files with 520 additions and 149 deletions

View file

@ -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

View file

@ -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.

View file

@ -20,10 +20,11 @@
./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
];
# Heather has 8 GiB RAM and runs several containers. Keep a 4 GiB
@ -71,6 +72,18 @@
# explicitly with `arion --prebuilt-file <active-unit-compose> 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).
@ -84,7 +97,6 @@
22067 # strelaysrv (relay)
22070 # strelaysrv (status)
3478 # coturn TURN (tcp+udp)
8443 # xray VLESS+Reality (публичный прокси)
];
firewall.allowedUDPPorts = [
22000 # syncthing sync (QUIC)

View file

@ -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" "10.42.0.0/16" ];
SocketBindDeny = "any";
SocketBindAllow = "ipv4:tcp:25580";
MemoryMax = "128M";
CPUQuota = "25%";
TasksMax = 20;
LimitNOFILE = 128;
};
};
}

View file

@ -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('<ii', req, kind) + text.encode('utf-8') + b'\0\0'
s.sendall(struct.pack('<i', len(b)) + b)
def receive():
length, = struct.unpack('<i', exact(4))
if not 10 <= length <= 65536:
raise OSError('Invalid RCON frame')
b = exact(length)
if b[-2:] != b'\0\0':
raise OSError('Invalid RCON terminator')
req, kind = struct.unpack('<ii', b[:8])
return req, kind, b[8:-2]
send(1, 3, self.password)
for _ in range(3):
req, kind, _ = receive()
if req == -1:
raise OSError('RCON auth failed')
if req == 1 and kind == 2:
break
else:
raise OSError('RCON auth acknowledgement missing')
send(2, 2, command)
# Minecraft processes RCON commands sequentially. A second safe
# command gives an unambiguous boundary for multi-packet replies.
send(3, 2, 'list')
chunks, size = [], 0
while True:
req, kind, data = receive()
if req == 3:
break
if req != 2 or kind != 0:
raise OSError('Unexpected RCON response')
size += len(data)
if size > 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()

View file

@ -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;
};
};
}

View file

@ -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;
};
};
}