Compare commits
3 commits
e8632cc9df
...
6e2ba02b9a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e2ba02b9a | ||
|
|
8c4840a0d3 | ||
|
|
3e40846b52 |
7 changed files with 520 additions and 149 deletions
|
|
@ -29,8 +29,6 @@ let
|
||||||
icon: forgejo.png
|
icon: forgejo.png
|
||||||
href: https://git.iscg.dev
|
href: https://git.iscg.dev
|
||||||
description: Git forge (ssh :2222)
|
description: Git forge (ssh :2222)
|
||||||
server: heather
|
|
||||||
container: forgejo
|
|
||||||
- Syncthing:
|
- Syncthing:
|
||||||
icon: syncthing.png
|
icon: syncthing.png
|
||||||
href: https://sync.iscg.dev
|
href: https://sync.iscg.dev
|
||||||
|
|
@ -44,33 +42,23 @@ let
|
||||||
icon: matrix.png
|
icon: matrix.png
|
||||||
href: https://iscg.dev
|
href: https://iscg.dev
|
||||||
description: Matrix homeserver (iscg.dev)
|
description: Matrix homeserver (iscg.dev)
|
||||||
server: heather
|
|
||||||
container: tuwunel
|
|
||||||
- Cinny:
|
- Cinny:
|
||||||
icon: cinny.png
|
icon: cinny.png
|
||||||
href: https://chat.iscg.dev
|
href: https://chat.iscg.dev
|
||||||
description: Matrix web client
|
description: Matrix web client
|
||||||
server: heather
|
|
||||||
container: cinny
|
|
||||||
- Jitsi Meet:
|
- Jitsi Meet:
|
||||||
icon: jitsi-meet.png
|
icon: jitsi-meet.png
|
||||||
href: https://jitsi.iscg.dev
|
href: https://jitsi.iscg.dev
|
||||||
description: Video conferencing
|
description: Video conferencing
|
||||||
server: heather
|
|
||||||
container: jitsi-web
|
|
||||||
- Productivity:
|
- Productivity:
|
||||||
- Silverbullet:
|
- Silverbullet:
|
||||||
icon: silverbullet.png
|
icon: silverbullet.png
|
||||||
href: https://note.iscg.dev
|
href: https://note.iscg.dev
|
||||||
description: Notes wiki (herbarium)
|
description: Notes wiki (herbarium)
|
||||||
server: heather
|
|
||||||
container: silverbullet
|
|
||||||
- Teable:
|
- Teable:
|
||||||
icon: /images/teable.png
|
icon: /images/teable.png
|
||||||
href: https://teable.iscg.dev
|
href: https://teable.iscg.dev
|
||||||
description: No-code database
|
description: No-code database
|
||||||
server: heather
|
|
||||||
container: teable
|
|
||||||
'';
|
'';
|
||||||
|
|
||||||
homepageSettingsYaml = pkgs.writeText "homepage-settings.yaml" ''
|
homepageSettingsYaml = pkgs.writeText "homepage-settings.yaml" ''
|
||||||
|
|
@ -86,8 +74,7 @@ let
|
||||||
searchDescriptions: true
|
searchDescriptions: true
|
||||||
hideInternetSearch: true
|
hideInternetSearch: true
|
||||||
hideVersion: true
|
hideVersion: true
|
||||||
# Live container status: green dot for healthy containers (docker-socket-proxy).
|
# Static navigation only: no Docker API integration.
|
||||||
statusStyle: dot
|
|
||||||
# Background photo (mounted ro at /app/public/images/ from /var/lib/homepage-images).
|
# Background photo (mounted ro at /app/public/images/ from /var/lib/homepage-images).
|
||||||
# cardBlur = frosted-glass / matte semi-transparent service & bookmark cards.
|
# cardBlur = frosted-glass / matte semi-transparent service & bookmark cards.
|
||||||
# NB: cardBlur incompatible with background blur/saturate/brightness filters,
|
# NB: cardBlur incompatible with background blur/saturate/brightness filters,
|
||||||
|
|
@ -105,14 +92,8 @@ let
|
||||||
href: https://git.iscg.dev/mikl/nix-config
|
href: https://git.iscg.dev/mikl/nix-config
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# Docker integration: live container status (green dot / UP) per service.
|
# Explicitly empty to avoid stale/default Docker integration configuration.
|
||||||
# docker-socket-proxy exposes read-only /containers/json. homepage talks to it
|
homepageDockerYaml = pkgs.writeText "homepage-docker.yaml" "{}\n";
|
||||||
# over the compose network (service name `dockerproxy`), no root on docker.sock.
|
|
||||||
homepageDockerYaml = pkgs.writeText "homepage-docker.yaml" ''
|
|
||||||
heather:
|
|
||||||
host: dockerproxy
|
|
||||||
port: 2375
|
|
||||||
'';
|
|
||||||
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
|
@ -195,26 +176,6 @@ in
|
||||||
service.ports = [ "127.0.0.1:8080:80" ];
|
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) ===
|
# === Teable (teable.iscg.dev) ===
|
||||||
# Стек: app + postgres + redis. Данные перенесены с iscg-dev (2026-07-12):
|
# Стек: app + postgres + redis. Данные перенесены с iscg-dev (2026-07-12):
|
||||||
# - postgres восстановлен из pg_dump (18M, consistent, без остановки источника)
|
# - 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 со всеми сервисами ===
|
# === Homepage (home.iscg.dev) — dashboard со всеми сервисами ===
|
||||||
# gethomepage/homepage: статичный список ссылок на все сервисы heather.
|
# gethomepage/homepage: статичный список ссылок на все сервисы heather.
|
||||||
# Конфиги (services/settings/bookmarks.yaml) генерятся через pkgs.writeText
|
# Конфиги (services/settings/bookmarks.yaml) генерятся через pkgs.writeText
|
||||||
|
|
|
||||||
|
|
@ -114,17 +114,16 @@ Allow: /" 200
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# paseo daemon endpoint для desktop/mobile-клиентов.
|
# Retired control plane: keep TLS but never proxy to a daemon.
|
||||||
# Basic Auth здесь намеренно нет: мобильный клиент Paseo его не умеет.
|
|
||||||
# Доступ защищён встроенным PASEO_PASSWORD самого демона.
|
|
||||||
"paseo.iscg.dev".extraConfig = ''
|
"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-хеш
|
# Disabled pending isolation from the administrative account.
|
||||||
# в /var/lib/jupyter-secrets/jupyter.env), наружу только HTTPS.
|
|
||||||
"jupyter.iscg.dev".extraConfig = ''
|
"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.
|
# Nick timer prototype. Public timer pages; creation has no auth for now.
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,11 @@
|
||||||
./caddy.nix # edge proxy, TLS, роутинг по доменам
|
./caddy.nix # edge proxy, TLS, роутинг по доменам
|
||||||
./syncthing.nix # sync + relay
|
./syncthing.nix # sync + relay
|
||||||
./tuwunel-admin.nix # matrix admin web UI (systemd, статический бинарник)
|
./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)
|
./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
|
./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
|
# 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`.
|
# explicitly with `arion --prebuilt-file <active-unit-compose> up -d --no-deps SERVICE`.
|
||||||
systemd.services.heather.restartIfChanged = false;
|
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 = {
|
networking = {
|
||||||
hostName = "heather";
|
hostName = "heather";
|
||||||
# Firewall выключен в ./image.nix (полагаемся на YC Security Groups).
|
# Firewall выключен в ./image.nix (полагаемся на YC Security Groups).
|
||||||
|
|
@ -84,7 +97,6 @@
|
||||||
22067 # strelaysrv (relay)
|
22067 # strelaysrv (relay)
|
||||||
22070 # strelaysrv (status)
|
22070 # strelaysrv (status)
|
||||||
3478 # coturn TURN (tcp+udp)
|
3478 # coturn TURN (tcp+udp)
|
||||||
8443 # xray VLESS+Reality (публичный прокси)
|
|
||||||
];
|
];
|
||||||
firewall.allowedUDPPorts = [
|
firewall.allowedUDPPorts = [
|
||||||
22000 # syncthing sync (QUIC)
|
22000 # syncthing sync (QUIC)
|
||||||
|
|
|
||||||
60
hosts/heather/minecraft-api.nix
Normal file
60
hosts/heather/minecraft-api.nix
Normal 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
406
hosts/heather/minecraft-api/server.py
Normal file
406
hosts/heather/minecraft-api/server.py
Normal 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()
|
||||||
|
|
@ -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;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +1,8 @@
|
||||||
# heather — tuwunel-admin (web admin UI for tuwunel Matrix homeserver).
|
# Public Matrix admin UI: a separate unprivileged, sandboxed identity.
|
||||||
#
|
# The root-owned binary and non-secret config remain read-only outside Nix.
|
||||||
# https://github.com/knadh/tuwunel-admin (Rust, axum, self-contained static binary).
|
# StateDirectory is deliberately DIFFERENT from the executable directory:
|
||||||
# Логинится matrix-пользователем на homeserver https://iscg.dev (см. [matrix]
|
# systemd must never make the binary/config writable by the service user.
|
||||||
# в 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 = {
|
systemd.services.tuwunel-admin = {
|
||||||
description = "tuwunel-admin (Matrix admin web UI)";
|
description = "tuwunel-admin (Matrix admin web UI)";
|
||||||
|
|
@ -23,13 +11,34 @@
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "simple";
|
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";
|
ExecStart = "/var/lib/tuwunel-admin/tuwunel-admin --config /var/lib/tuwunel-admin/config.toml";
|
||||||
WorkingDirectory = "/var/lib/tuwunel-admin";
|
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
RestartSec = 5;
|
RestartSec = 5;
|
||||||
# Бинарь статический, рут-запуск ок (биндит 127.0.0.1:8009).
|
UMask = "0077";
|
||||||
User = "root";
|
NoNewPrivileges = true;
|
||||||
Environment = [ "HOME=/var/lib/tuwunel-admin" ];
|
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;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue