Initial import: bare-metal stacks, Server UI, GPU fan, tutorials.

Infrastructure configs for GMKtec K11 (Docker, vLLM, LocalAI, ComfyUI,
control-plane, gpu-fan agent, Server UI with CLI/file explorer/GPU fan curve).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
tomasz-syn-grzegorza
2026-07-05 12:02:04 +00:00
commit 359afb3a59
153 changed files with 18169 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
# Data disk mount point
DATA_ROOT=/data
# NPMPlus image
NPMPLUS_IMAGE=docker.io/zoeyvid/npmplus:latest
# Required for Let's Encrypt certificates
ACME_EMAIL=admin@example.com
# Timezone (TZ database name)
TZ=Europe/Warsaw
# Optional: set on first start instead of random password in logs
# INITIAL_ADMIN_EMAIL=admin@example.com
# INITIAL_ADMIN_PASSWORD=change-me-strong-password
# Bind admin UI (port 81) to localhost only — use with SSH tunnel
# NPM_LISTEN_LOCALHOST=true
# Disable IPv6 listeners if your network has no IPv6
# DISABLE_IPV6=true
+1
View File
@@ -0,0 +1 @@
.env
+89
View File
@@ -0,0 +1,89 @@
# NPMPlus — reverse proxy + Let's Encrypt
[NPMPlus](https://github.com/ZoeyVid/NPMPlus) (fork nginx-proxy-manager) exposes backend services on **HTTPS** with automatic certificates.
On this host it proxies **LocalAI** at `https://llm.rtx1.mobile.agency-ai.dev``http://127.0.0.1:8070`.
## Ports (host network)
| Port | Protocol | Service |
|------|----------|---------|
| 80 | tcp | HTTP (ACME challenge + redirects) |
| 443 | tcp, udp | HTTPS / HTTP3 |
| 81 | tcp | NPMPlus admin UI (HTTPS) |
## Prerequisites
1. `/data` mounted (tutorial 04)
2. DNS `A` record: `llm.rtx1.mobile.agency-ai.dev` → public IP
3. Router port forward: `80`, `443/tcp`, `443/udp``192.168.100.5`
4. `ACME_EMAIL` in `.env` (Let's Encrypt notifications)
## Quick start
```bash
cd stacks/npmplus
cp .env.example .env
# Edit ACME_EMAIL (and optionally INITIAL_ADMIN_*)
./scripts/start.sh
```
Admin UI: `https://<LAN-IP>:81` (default login `admin@example.org` unless `INITIAL_ADMIN_EMAIL` set).
## Admin access (LAN)
Use **`https://192.168.100.90:81`** (or your LAN IP), **not** `http://`.
| Symptom | Cause |
|---------|-------|
| `http://IP:81` does not load UI | Port 81 serves HTTPS only; HTTP returns 308 redirect |
| Browser shows certificate error | Default cert has no SAN for IP; run cert regeneration (below) |
| `docker ps` shows empty PORTS | Normal with `network_mode: host` — check with `ss -tlnp \| grep ':81'` |
Regenerate admin cert with SAN for your LAN IP:
```bash
sudo ./scripts/regenerate-admin-cert.sh
```
After regeneration, open `https://<LAN-IP>:81`, click through the self-signed cert warning once, then log in.
## Proxy host — LocalAI
In NPMPlus UI → **Hosts****Proxy Hosts** → Add:
| Field | Value |
|-------|-------|
| Domain | `llm.rtx1.mobile.agency-ai.dev` |
| Scheme | `http` |
| Forward hostname | `127.0.0.1` |
| Forward port | `8070` |
| SSL | Request new certificate, Force SSL, HTTP/2 |
| Websockets | Enabled |
Clients send `Authorization: Bearer <LOCALAI_API_KEY>` (same key as in `stacks/localai/.env`).
Test:
```bash
curl -s https://llm.rtx1.mobile.agency-ai.dev/v1/models \
-H "Authorization: Bearer $LOCALAI_API_KEY"
```
## Firewall
```bash
sudo ./scripts/configure-firewall.sh
```
Restricts direct access to LocalAI (8070) and gpu-fan (8090); allows 80/443 for NPMPlus.
## Data
Persistent files: `/data/apps/npmplus/`
## Related
- LocalAI stack: [`../localai/README.md`](../localai/README.md)
- Client handoff: [`../localai/coding-agent/APP-CLIENT-HANDOFF.md`](../localai/coding-agent/APP-CLIENT-HANDOFF.md)
- Tutorial: [`../../manual-tutorial/07-npmplus-reverse-proxy.md`](../../manual-tutorial/07-npmplus-reverse-proxy.md)
+1
View File
@@ -0,0 +1 @@
docker-compose.yml
+27
View File
@@ -0,0 +1,27 @@
name: npmplus
services:
npmplus:
image: ${NPMPLUS_IMAGE:-docker.io/zoeyvid/npmplus:latest}
container_name: npmplus
profiles:
- npmplus
restart: unless-stopped
network_mode: host
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
- SETGID
- DAC_OVERRIDE
security_opt:
- no-new-privileges:true
volumes:
- ${DATA_ROOT:-/data}/apps/npmplus:/data
environment:
- TZ=${TZ:-Europe/Warsaw}
- ACME_EMAIL=${ACME_EMAIL}
- INITIAL_ADMIN_EMAIL=${INITIAL_ADMIN_EMAIL:-}
- INITIAL_ADMIN_PASSWORD=${INITIAL_ADMIN_PASSWORD:-}
- NPM_LISTEN_LOCALHOST=${NPM_LISTEN_LOCALHOST:-false}
- DISABLE_IPV6=${DISABLE_IPV6:-false}
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# UFW rules for NPMPlus + LocalAI hardening. Run: sudo ./scripts/configure-firewall.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo ${SCRIPT_DIR}/configure-firewall.sh"
exit 1
fi
LAN_CIDR="${LAN_CIDR:-192.168.100.0/24}"
echo "=== NPMPlus firewall (ufw) ==="
echo "LAN CIDR for admin port 81: ${LAN_CIDR}"
echo ""
if ! command -v ufw &>/dev/null; then
echo "Installing ufw..."
apt-get update -qq && apt-get install -y ufw
fi
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp comment 'SSH'
ufw allow 80/tcp comment 'NPMPlus HTTP / ACME'
ufw allow 443/tcp comment 'NPMPlus HTTPS'
ufw allow 443/udp comment 'NPMPlus HTTP/3'
ufw allow from "${LAN_CIDR}" to any port 81 proto tcp comment 'NPMPlus admin LAN only'
ufw deny 8070/tcp comment 'LocalAI localhost only'
ufw deny 8090/tcp comment 'gpu-fan not public'
echo ""
echo "Rules to apply:"
ufw show added || true
echo ""
ufw --force enable
ufw status verbose
echo ""
echo "Done. Admin UI: https://<LAN-IP>:81 (from ${LAN_CIDR} only)"
echo "Public HTTPS: ports 80/443"
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# Create NPMPlus proxy host for LocalAI + request Let's Encrypt cert.
# Usage: ./scripts/configure-localai-proxy.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${STACK_DIR}"
if [[ ! -f .env ]]; then
echo "ERROR: .env not found"
exit 1
fi
set -a
# shellcheck disable=SC1091
source .env
set +a
export INITIAL_ADMIN_EMAIL INITIAL_ADMIN_PASSWORD ACME_EMAIL \
LOCALAI_PROXY_DOMAIN LOCALAI_FORWARD_PORT
python3 <<'PY'
import json
import os
import ssl
import sys
import urllib.error
import urllib.request
import http.cookiejar
domain = os.environ.get("LOCALAI_PROXY_DOMAIN", "llm.rtx1.mobile.agency-ai.dev")
forward_port = int(os.environ.get("LOCALAI_FORWARD_PORT", "8070"))
admin_email = os.environ.get("INITIAL_ADMIN_EMAIL", "")
admin_pass = os.environ.get("INITIAL_ADMIN_PASSWORD", "")
acme_email = os.environ.get("ACME_EMAIL", admin_email)
if not admin_email or not admin_pass:
print("ERROR: Set INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD in .env", file=sys.stderr)
sys.exit(1)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(cj),
urllib.request.HTTPSHandler(context=ctx),
)
def api(method: str, path: str, payload=None):
data = None
headers = {}
if payload is not None:
data = json.dumps(payload).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(f"https://127.0.0.1:81{path}", data=data, headers=headers, method=method)
with opener.open(req) as resp:
body = resp.read()
return json.loads(body) if body else {}
try:
api("POST", "/api/tokens", {"identity": admin_email, "secret": admin_pass})
except urllib.error.HTTPError as e:
print(f"ERROR: NPMPlus login failed ({e.code})", file=sys.stderr)
print(e.read().decode()[:300], file=sys.stderr)
sys.exit(1)
if not any(c.name == "__Host-Http-token" for c in cj):
print("ERROR: NPMPlus login did not return session cookie", file=sys.stderr)
sys.exit(1)
hosts = api("GET", "/api/nginx/proxy-hosts")
for host in hosts:
if domain in host.get("domain_names", []):
print(f"Proxy host already exists (id={host['id']}) for {domain}")
sys.exit(0)
payload = {
"domain_names": [domain],
"forward_scheme": "http",
"forward_host": "127.0.0.1",
"forward_port": forward_port,
"access_list_id": 0,
"certificate_id": "new",
"ssl_forced": True,
"http2_support": True,
"block_exploits": True,
"allow_websocket_upgrade": True,
"meta": {
"letsencrypt_email": acme_email,
"letsencrypt_agree": True,
"dns_challenge": False,
},
}
try:
result = api("POST", "/api/nginx/proxy-hosts", payload)
except urllib.error.HTTPError as e:
print(f"ERROR: create proxy host failed ({e.code})", file=sys.stderr)
print(e.read().decode()[:500], file=sys.stderr)
sys.exit(1)
print(json.dumps(result, indent=2))
print(f"\nProxy host created for https://{domain} -> 127.0.0.1:{forward_port}")
print("Certificate issuance may take 1-2 minutes (check NPMPlus logs).")
PY
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Enable HTTP proxy for LocalAI (no SSL) — use when LE cert is pending port-forward.
# After cert works, enable SSL in NPMPlus UI or re-run configure-localai-proxy.sh.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${STACK_DIR}"
set -a
# shellcheck disable=SC1091
source .env
set +a
export INITIAL_ADMIN_EMAIL INITIAL_ADMIN_PASSWORD LOCALAI_PROXY_DOMAIN LOCALAI_FORWARD_PORT
python3 <<'PY'
import json
import os
import ssl
import sys
import urllib.error
import urllib.request
import http.cookiejar
domain = os.environ.get("LOCALAI_PROXY_DOMAIN", "llm.rtx1.mobile.agency-ai.dev")
forward_port = int(os.environ.get("LOCALAI_FORWARD_PORT", "8070"))
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(cj),
urllib.request.HTTPSHandler(context=ctx),
)
def api(method, path, payload=None):
data = json.dumps(payload).encode() if payload is not None else None
headers = {"Content-Type": "application/json"} if payload is not None else {}
req = urllib.request.Request(
f"https://127.0.0.1:81{path}", data=data, headers=headers, method=method
)
with opener.open(req) as resp:
body = resp.read()
return json.loads(body) if body else {}
api("POST", "/api/tokens", {
"identity": os.environ["INITIAL_ADMIN_EMAIL"],
"secret": os.environ["INITIAL_ADMIN_PASSWORD"],
})
hosts = api("GET", "/api/nginx/proxy-hosts")
host = next((h for h in hosts if domain in h.get("domain_names", [])), None)
if not host:
print(f"No proxy host for {domain}", file=sys.stderr)
sys.exit(1)
host_id = host["id"]
payload = {
**{k: host[k] for k in host if k not in ("id", "created_on", "modified_on", "meta")},
"ssl_forced": False,
"certificate_id": 0,
"http2_support": False,
"allow_websocket_upgrade": True,
"forward_scheme": "http",
"forward_host": "127.0.0.1",
"forward_port": forward_port,
}
try:
result = api("PUT", f"/api/nginx/proxy-hosts/{host_id}", payload)
except urllib.error.HTTPError as e:
print(f"ERROR ({e.code}):", e.read().decode()[:500], file=sys.stderr)
sys.exit(1)
print(f"Updated proxy host {host_id}: HTTP only until LE cert is ready")
print(json.dumps({"id": result.get("id"), "domain_names": result.get("domain_names")}, indent=2))
PY
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Create NPMPlus data directory on the data disk.
ensure_npmplus_dirs() {
local data_root="${1:-/data}"
mkdir -p "${data_root}/apps/npmplus"
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
ensure_npmplus_dirs "${1:-/data}"
fi
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Regenerate NPMPlus admin UI TLS cert (port 81) with SAN for LAN IP access.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${STACK_DIR}"
if [[ -f .env ]]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
DATA_ROOT="${DATA_ROOT:-/data}"
TLS_DIR="${DATA_ROOT}/apps/npmplus/tls"
LAN_IP="${LAN_IP:-$(hostname -I 2>/dev/null | awk '{print $1}')}"
HOST_SHORT="${HOST_SHORT:-$(hostname -s 2>/dev/null || hostname)}"
if [[ -z "${LAN_IP}" ]]; then
echo "ERROR: Could not detect LAN IP. Set LAN_IP in environment."
exit 1
fi
if [[ ! -d "${TLS_DIR}" ]]; then
echo "ERROR: ${TLS_DIR} not found. Start NPMPlus once: ./scripts/start.sh"
exit 1
fi
SAN="IP:${LAN_IP},DNS:${HOST_SHORT},DNS:localhost"
TS="$(date +%Y%m%d%H%M%S)"
echo "=== NPMPlus admin cert regeneration ==="
echo "TLS dir: ${TLS_DIR}"
echo "SAN: ${SAN}"
echo ""
if [[ "${EUID}" -ne 0 ]]; then
echo "Re-run as root to write certs owned by container:"
echo " sudo ${SCRIPT_DIR}/regenerate-admin-cert.sh"
exit 1
fi
for f in dummycert.pem dummykey.pem; do
if [[ -f "${TLS_DIR}/${f}" ]]; then
cp -a "${TLS_DIR}/${f}" "${TLS_DIR}/${f}.bak.${TS}"
echo "Backed up ${f} -> ${f}.bak.${TS}"
fi
done
TMP="$(mktemp -d)"
trap 'rm -rf "${TMP}"' EXIT
openssl req -x509 -nodes -days 3650 -newkey rsa:4096 \
-keyout "${TMP}/dummykey.pem" \
-out "${TMP}/dummycert.pem" \
-subj "/CN=${HOST_SHORT}" \
-addext "subjectAltName=${SAN}"
install -m 600 -o root -g root "${TMP}/dummykey.pem" "${TLS_DIR}/dummykey.pem"
install -m 644 -o root -g root "${TMP}/dummycert.pem" "${TLS_DIR}/dummycert.pem"
echo ""
echo "Installed new cert. Verifying SAN:"
openssl x509 -in "${TLS_DIR}/dummycert.pem" -noout -text | grep -A1 'Subject Alternative Name' || true
if docker ps --format '{{.Names}}' | grep -qx npmplus; then
echo ""
echo "Restarting npmplus..."
docker compose --profile npmplus restart npmplus
else
echo ""
echo "Container npmplus not running — start with: ./scripts/start.sh"
fi
echo ""
echo "Admin UI: https://${LAN_IP}:81"
echo "Use HTTPS (not http). Accept the self-signed cert warning once in the browser."
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/ensure-dirs.sh"
cd "${STACK_DIR}"
if [[ ! -f .env ]]; then
echo "ERROR: .env not found. Run: cp .env.example .env"
exit 1
fi
set -a
# shellcheck disable=SC1091
source .env
set +a
DATA_ROOT="${DATA_ROOT:-/data}"
if [[ -z "${ACME_EMAIL:-}" || "${ACME_EMAIL}" == "admin@example.com" ]]; then
echo "ERROR: Set ACME_EMAIL in .env (required for Let's Encrypt)"
exit 1
fi
if ! mountpoint -q "${DATA_ROOT}" 2>/dev/null; then
echo "ERROR: ${DATA_ROOT} is not mounted"
exit 1
fi
ensure_npmplus_dirs "${DATA_ROOT}"
if ! docker info &>/dev/null; then
echo "ERROR: Docker is not running"
exit 1
fi
if ss -tlnp 2>/dev/null | grep -qE ':80 |:443 '; then
echo "WARNING: Port 80 or 443 already in use — NPMPlus needs both for Let's Encrypt"
ss -tlnp 2>/dev/null | grep -E ':80 |:443 ' || true
fi
echo "=== NPMPlus stack ==="
echo "Image: ${NPMPLUS_IMAGE:-docker.io/zoeyvid/npmplus:latest}"
echo "Data: ${DATA_ROOT}/apps/npmplus"
echo "Ports: 80, 443 (tcp+udp), 81 (admin HTTPS)"
echo "ACME: ${ACME_EMAIL}"
echo ""
docker compose --profile npmplus pull
docker compose --profile npmplus up -d
echo ""
echo "Started. Admin UI (HTTPS only — http:// redirects and may fail in browser):"
echo " https://$(hostname -I 2>/dev/null | awk '{print $1}'):81"
echo ""
echo "If the browser blocks the cert when opening by IP, run:"
echo " sudo ./scripts/regenerate-admin-cert.sh"
echo ""
echo "First login — check logs for random password if INITIAL_ADMIN_PASSWORD unset:"
echo " docker compose --profile npmplus logs npmplus | grep -i password"
echo ""
echo "Prerequisites before proxy host + SSL:"
echo " DNS A record → this host's public IP"
echo " Router port forward: 80/tcp, 443/tcp, 443/udp → this host"