ITYS I Told You So

5 min

Moderate friction gates

Bots hate gates, no, not Bill.

🔗Building Delphi’s PoH / PoW Gateway for SearXNG

This document describes how the Proof‑of‑Human (PoH) / Proof‑of‑Work (PoW) gate in front of SearXNG on delphi was built and wired up, from a clean state to the current Tailscale‑aware deployment. Why Tailscale, it’s WireGuard with a nice admin interface and is easy to deploy.

Audience: experienced operators / developers who are comfortable with Linux, systemd, Python, and Caddy.

The examples are written to be copy/paste‑friendly and assume:

  • Hostname: delphi.example.com
  • Tailscale hostname: delphi.abc123.ts.net
  • SearXNG running under uWSGI, listening on 127.0.0.1:8888 (HTTP)
  • PoH app running under uvicorn on 127.0.0.1:9000

Adjust paths and hostnames to taste.


đź”—1. Overview

The master plan:

  • All traffic to https://delphi.example.com/ (and the Tailscale name) lands on a PoH page.
  • The page shows a “Don’t Panic” logo and a Tron‑style traveling border and a single instruction to “Hold to enter”.
  • Holding on the logo runs a client‑side PoW (SHA‑256 based on a server challenge), signs the result with a browser ECDSA key, and sends it to the backend for verification.
  • On success, the backend:
    • Verifies the PoW and signature.
    • Issues a short‑lived session cookie (poh_session) bound to the client IP / User Agent.
  • Caddy only proxies /searxng/* (SearXNG) when the poh_session cookie is present; otherwise it sends the user back to the PoH gate.

đź”—The bot friction layers:

  • PoW with adjustable difficulty based on interaction.
  • Per‑IP rate limiting on PoH challenge creation.
  • Session TTL shortened for “suspicious” clients (those that never fetched favicon.ico).
  • Tailscale DNS name terminates TLS via a Tailscale‑issued cert, but shares the same routing and gate.

đź”—2. PoH App: Python / FastAPI Setup

To replicate this build, create a dedicated directory and virtualenv on the server:

sudo mkdir -p /opt/poh
sudo chown "$USER":"$USER" /opt/poh
cd /opt/poh

python3 -m venv venv
./venv/bin/pip install --upgrade pip
./venv/bin/pip install fastapi "uvicorn[standard]" cryptography

Create the app layout:

mkdir -p /opt/poh/app/static

Create /opt/poh/app/main.py:

from datetime import datetime, timedelta, timezone
import base64
import hashlib
import secrets
from pathlib import Path
from typing import Any, Dict

from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import HTMLResponse, FileResponse
from pydantic import BaseModel
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature


app = FastAPI()


class StartResponse(BaseModel):
  challenge: str
  difficulty: int
  timestamp: str


class VerifyRequest(BaseModel):
  challenge: str
  counter: int
  hash: str
  signature: str
  public_key: Dict[str, Any]
  timestamp: str | None = None


CHALLENGE_TTL = timedelta(minutes=5)
DEFAULT_DIFFICULTY = 20
SESSION_TTL = timedelta(minutes=20)


_challenges: Dict[str, Dict[str, Any]] = {}
_sessions: Dict[str, Dict[str, Any]] = {}
_rate_starts: Dict[str, list[datetime]] = {}
_favicon_hits: Dict[str, datetime] = {}


def _now() -> datetime:
  return datetime.now(timezone.utc)


def _client_ip(request: Request) -> str:
  fwd = request.headers.get("x-forwarded-for")
  if fwd:
    return fwd.split(",")[0].strip()
  if request.client:
    return request.client.host or "?"
  return "?"


def _b64url_decode(data: str) -> bytes:
  padding = "=" * ((4 - len(data) % 4) % 4)
  return base64.urlsafe_b64decode((data + padding).encode("ascii"))


def _public_key_from_jwk(jwk: Dict[str, Any]) -> ec.EllipticCurvePublicKey:
  if jwk.get("kty") != "EC":
    raise ValueError("Unsupported key type")
  crv = jwk.get("crv")
  if crv not in ("P-256", "secp256r1"):
    raise ValueError("Unsupported curve")

  x_b = _b64url_decode(jwk["x"])
  y_b = _b64url_decode(jwk["y"])
  x = int.from_bytes(x_b, "big")
  y = int.from_bytes(y_b, "big")

  curve = ec.SECP256R1()
  public_numbers = ec.EllipticCurvePublicNumbers(x, y, curve)
  return public_numbers.public_key()


def _leading_zero_bits_from_hex(h: str) -> int:
  b = bytes.fromhex(h)
  bits = 0
  for byte in b:
    if byte == 0:
      bits += 8
    else:
      bits += 8 - byte.bit_length()
      break
  return bits


def _rate_limit_start(ip: str) -> None:
  now = _now()
  window = timedelta(minutes=1)
  entries = _rate_starts.setdefault(ip, [])
  entries[:] = [t for t in entries if now - t < window]
  if len(entries) >= 20:
    raise HTTPException(status_code=429, detail="Too many PoH starts from this IP")
  entries.append(now)


@app.get("/poh/start", response_model=StartResponse)
async def start_poh(request: Request) -> StartResponse:
  ip = _client_ip(request)
  _rate_limit_start(ip)

  now = _now()
  seen = _favicon_hits.get(ip)
  suspicious = True
  if seen is not None and now - seen < timedelta(minutes=10):
    suspicious = False

  difficulty = DEFAULT_DIFFICULTY + 4 if suspicious else DEFAULT_DIFFICULTY
  challenge = secrets.token_hex(16)

  _challenges[challenge] = {
    "created_at": now,
    "difficulty": difficulty,
    "used": False,
    "suspicious": suspicious,
  }

  return StartResponse(
    challenge=challenge,
    difficulty=difficulty,
    timestamp=now.isoformat(),
  )


@app.post("/poh/verify")
async def verify_poh(request: Request, response: Response, body: VerifyRequest):
  meta = _challenges.get(body.challenge)
  if not meta:
    raise HTTPException(status_code=400, detail="Unknown challenge")

  if meta["used"]:
    raise HTTPException(status_code=400, detail="Challenge already used")

  created_at: datetime = meta["created_at"]
  if _now() - created_at > CHALLENGE_TTL:
    raise HTTPException(status_code=400, detail="Challenge expired")

  difficulty = int(meta["difficulty"])

  data = (body.challenge + str(body.counter)).encode("utf-8")
  digest = hashlib.sha256(data).hexdigest()

  if digest != body.hash:
    raise HTTPException(status_code=400, detail="Hash mismatch")

  leading_bits = _leading_zero_bits_from_hex(digest)
  if leading_bits < difficulty:
    raise HTTPException(status_code=400, detail="Insufficient difficulty")

  try:
    pub = _public_key_from_jwk(body.public_key)
  except Exception as exc:  # noqa: BLE001
    raise HTTPException(status_code=400, detail=f"Invalid public key: {exc}") from exc

  message = (body.challenge + str(body.counter) + body.hash).encode("utf-8")

  try:
    sig_bytes = base64.b64decode(body.signature.encode("ascii"))
  except Exception as exc:  # noqa: BLE001
    raise HTTPException(status_code=400, detail="Invalid signature encoding") from exc

  if len(sig_bytes) != 64:
    raise HTTPException(status_code=400, detail="Unexpected signature length")

  r = int.from_bytes(sig_bytes[:32], "big")
  s = int.from_bytes(sig_bytes[32:], "big")
  der_sig = encode_dss_signature(r, s)

  try:
    pub.verify(der_sig, message, ec.ECDSA(hashes.SHA256()))
  except Exception as exc:  # noqa: BLE001
    raise HTTPException(status_code=400, detail="Signature verification failed") from exc

  meta["used"] = True

  now = _now()
  session_id = secrets.token_urlsafe(32)
  ip = _client_ip(request)
  ua = request.headers.get("user-agent", "?")

  suspicious = bool(meta.get("suspicious"))
  ttl = SESSION_TTL if not suspicious else timedelta(minutes=5)

  _sessions[session_id] = {
    "created_at": now,
    "expires_at": now + ttl,
    "ip": ip,
    "ua": ua,
  }

  response.set_cookie(
    "poh_session",
    session_id,
    max_age=int(ttl.total_seconds()),
    httponly=True,
    secure=True,
    samesite="Lax",
    path="/searxng/",
  )

  return {"status": "ok"}


@app.get("/", response_class=HTMLResponse)
async def root() -> HTMLResponse:
  html = """<!DOCTYPE html>
<html lang=\"en\">
  <head>
    <meta charset=\"utf-8\" />
    <title>Proof of Human</title>
    <style>
      :root { color-scheme: dark; }
      body {
        margin: 0;
        min-height: 100vh;
        display: flex;
        align-items: center;
        justify-content: center;
        background: radial-gradient(circle at top, #3b82f6 0, #1e3a8a 45%, #020617 100%);
        font-family: system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;
        color: #e0f2f1;
      }
      .shell {
        text-align: center;
      }
      #holder {
        position: relative;
        display: inline-block;
        cursor: pointer;
      }
      #dontpanic-logo {
        display: block;
        max-width: 260px;
        width: 70vw;
        height: auto;
        border-radius: 18px;
        filter: drop-shadow(0 0 16px rgba(15, 23, 42, 0.85));
      }
      #glow {
        position: absolute;
        left: -4px;
        top: -4px;
        width: calc(100% + 8px);
        height: calc(100% + 8px);
        pointer-events: none;
      }
      #glow-rect {
        fill: none;
        stroke: #00f2ff;
        stroke-width: 4;
        stroke-linecap: round;
        stroke-linejoin: round;
        filter: drop-shadow(0 0 6px #00f2ff);
        opacity: 0.95;
      }
      #hint {
        margin-top: 0.75rem;
        font-size: 0.95rem;
        letter-spacing: 0.06em;
        text-transform: uppercase;
        color: #cbd5f5;
      }
      #status {
        margin-top: 0.75rem;
        font-size: 0.95rem;
        opacity: 0.9;
      }
      #attempts,
      #eta {
        margin-top: 0.3rem;
        font-size: 0.9rem;
        opacity: 0.85;
      }
    </style>
  </head>
  <body>
    <div class=\"shell\">
      <div id=\"holder\">
        <img
          id=\"dontpanic-logo\"
          src=\"/dontpanic.png\"
          alt=\"Don't Panic logo\"
        />
        <svg id=\"glow\" aria-hidden=\"true\"><rect id=\"glow-rect\" x=\"2\" y=\"2\" width=\"100\" height=\"100\" rx=\"18\" /></svg>
      </div>
      <div id=\"hint\">Hold to enter</div>
      <div id=\"attempts\"></div>
      <div id=\"eta\"></div>
      <div id=\"status\"></div>
    </div>
    <script src=\"/poh.js\"></script>
  </body>
</html>
"""
  return HTMLResponse(content=html)


JS_PATH = Path(__file__).parent / "static" / "poh.js"
IMG_PATH = Path(__file__).parent / "static" / "dontpanic.png"
FAVICON_PATH = Path(__file__).parent / "static" / "favicon.ico"


@app.get("/poh.js")
async def poh_js() -> FileResponse:
  return FileResponse(JS_PATH, media_type="application/javascript")


@app.get("/dontpanic.png")
async def dontpanic_png() -> FileResponse:
  return FileResponse(IMG_PATH, media_type="image/png")


@app.get("/favicon.ico")
async def favicon_ico(request: Request) -> FileResponse:
  ip = _client_ip(request)
  _favicon_hits[ip] = _now()
  return FileResponse(FAVICON_PATH, media_type="image/x-icon")


đź”—3. Frontend PoH / PoW Script (poh.js)

So with the back-end handling the verification, the browser-side logic lives in /opt/poh/app/static/poh.js. It:

  • Ensures a persistent ECDSA P‑256 keypair (stored in localStorage as JWK).
  • Fetches a challenge from /poh/start when the user holds the logo.
  • Runs the PoW loop (SHA-256(challenge + counter)) until the hash meets the difficulty.
  • Signs (challenge + counter + hash) with the private key using WebCrypto (ECDSA + SHA-256).
  • Sends the signed payload to /poh/verify and redirects to /searxng/ on success.

đź”—Minimal version:

(async () => {
  const holder = document.getElementById("holder");
  const statusEl = document.getElementById("status");
  const attemptsEl = document.getElementById("attempts");
  const etaEl = document.getElementById("eta");
  const glowSvg = document.getElementById("glow");
  const glowRect = document.getElementById("glow-rect");

  if (!holder || !statusEl) {
    console.error("PoH UI elements not found");
    return;
  }

  let holding = false;

  function setStatus(text) {
    statusEl.textContent = text;
  }

  function setAttempts(count) {
    if (attemptsEl) attemptsEl.textContent = `Attempts: ${count.toLocaleString()}`;
  }

  function setEta(text) {
    if (etaEl) etaEl.textContent = text;
  }

  function toHex(buffer) {
    const bytes = new Uint8Array(buffer);
    return Array.from(bytes)
      .map(b => b.toString(16).padStart(2, "0"))
      .join("");
  }

  async function sha256Hex(str) {
    const enc = new TextEncoder();
    const data = enc.encode(str);
    const digest = await crypto.subtle.digest("SHA-256", data);
    return toHex(digest);
  }

  function leadingZeroBitsFromHex(hex) {
    const bytes = new Uint8Array(hex.length / 2);
    for (let i = 0; i < bytes.length; i++) {
      bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
    }
    let bits = 0;
    for (let i = 0; i < bytes.length; i++) {
      const byte = bytes[i];
      if (byte === 0) bits += 8;
      else {
        bits += 8 - byte.toString(2).length;
        break;
      }
    }
    return bits;
  }

  const STORAGE_KEY = "poh-keypair";

  async function storeKeyPair(keyPair) {
    const privJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
    const pubJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
    localStorage.setItem(STORAGE_KEY, JSON.stringify({ privateKey: privJwk, publicKey: pubJwk }));
  }

  async function loadKeyPair() {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return null;
    try {
      const payload = JSON.parse(raw);
      const priv = await crypto.subtle.importKey(
        "jwk",
        payload.privateKey,
        { name: "ECDSA", namedCurve: "P-256" },
        true,
        ["sign"],
      );
      const pub = await crypto.subtle.importKey(
        "jwk",
        payload.publicKey,
        { name: "ECDSA", namedCurve: "P-256" },
        true,
        ["verify"],
      );
      return { privateKey: priv, publicKey: pub, publicJwk: payload.publicKey };
    } catch (e) {
      console.error("Failed to load keypair", e);
      return null;
    }
  }

  async function getOrCreateKeyPair() {
    const existing = await loadKeyPair();
    if (existing) return existing;
    const generated = await crypto.subtle.generateKey(
      { name: "ECDSA", namedCurve: "P-256" },
      true,
      ["sign", "verify"],
    );
    await storeKeyPair(generated);
    const pubJwk = await crypto.subtle.exportKey("jwk", generated.publicKey);
    return { privateKey: generated.privateKey, publicKey: generated.publicKey, publicJwk: pubJwk };
  }

  // Tron glow
  let glowLen = 0;
  let glowOffset = 0;
  let glowAnimating = false;
  let bestBits = 0;

  function layoutGlow() {
    if (!glowSvg || !glowRect || !holder) return;
    const w = holder.offsetWidth + 8;
    const h = holder.offsetHeight + 8;
    glowSvg.setAttribute("viewBox", `0 0 ${w} ${h}`);
    glowSvg.setAttribute("width", String(w));
    glowSvg.setAttribute("height", String(h));
    glowRect.setAttribute("x", "2");
    glowRect.setAttribute("y", "2");
    glowRect.setAttribute("width", String(w - 4));
    glowRect.setAttribute("height", String(h - 4));
    glowRect.setAttribute("rx", "18");
    try {
      glowLen = glowRect.getTotalLength();
    } catch {
      glowLen = w * 2 + h * 2;
    }
    glowRect.style.strokeDasharray = `0 ${glowLen}`;
    glowRect.style.strokeDashoffset = "0";
  }

  function setProgress(progress) {
    if (!glowRect || !glowLen) return;
    const p = Math.max(0, Math.min(1, progress));
    const lit = Math.max(12, Math.floor(glowLen * p));
    glowRect.style.strokeDasharray = `${lit} ${glowLen - lit}`;
  }

  function tickGlow() {
    if (!glowAnimating || !glowRect || !glowLen) return;
    glowOffset = (glowOffset + 6) % glowLen;
    glowRect.style.strokeDashoffset = String(-glowOffset);
    requestAnimationFrame(tickGlow);
  }

  window.addEventListener("resize", layoutGlow);

  async function runPoW(challenge, difficulty) {
    setStatus("Running proof-of-work...");
    setAttempts(0);
    setEta("Estimating hold time...");

    layoutGlow();
    glowAnimating = true;
    bestBits = 0;
    setProgress(0);
    requestAnimationFrame(tickGlow);

    let counter = 0;
    const batchSize = 1000;
    const start = performance.now();
    let lastUpdate = start;

    // eslint-disable-next-line no-constant-condition
    while (true) {
      if (!holding) {
        setStatus("Released; PoH paused. Hold to resume.");
        glowAnimating = false;
        return null;
      }

      const hash = await sha256Hex(challenge + String(counter));
      const bits = leadingZeroBitsFromHex(hash);
      if (bits > bestBits) {
        bestBits = bits;
        if (difficulty > 0) setProgress(bestBits / difficulty);
      }
      if (bits >= difficulty) {
        const now = performance.now();
        const elapsedSec = (now - start) / 1000;
        setAttempts(counter);
        setEta(`Done in ${elapsedSec.toFixed(1)}s`);
        glowAnimating = false;
        setProgress(1);
        return { challenge, counter, hash, difficulty };
      }
      counter++;
      if (counter % batchSize === 0) {
        const now = performance.now();
        if (now - lastUpdate >= 250) {
          lastUpdate = now;
          const elapsedSec = (now - start) / 1000;
          setAttempts(counter);
          if (elapsedSec > 0) {
            const rate = counter / elapsedSec;
            const expectedTotal = Math.pow(2, difficulty);
            const remaining = Math.max(expectedTotal - counter, 0);
            const etaSec = remaining / rate;
            const clamped = Math.max(0, Math.min(3600, etaSec));
            setEta(`Estimated time remaining: ${clamped.toFixed(1)}s`);
          } else {
            setEta("Estimating hold time...");
          }
          await new Promise(resolve => setTimeout(resolve, 0));
        }
      }
    }
  }

  function arrayBufferToBase64(buffer) {
    const bytes = new Uint8Array(buffer);
    let binary = "";
    for (let i = 0; i < bytes.length; i++) {
      binary += String.fromCharCode(bytes[i]);
    }
    return btoa(binary);
  }

  async function signPoW(privateKey, result, publicJwk) {
    const message = result.challenge + String(result.counter) + result.hash;
    const enc = new TextEncoder();
    const data = enc.encode(message);
    const sig = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, privateKey, data);
    const signatureB64 = arrayBufferToBase64(sig);
    return {
      challenge: result.challenge,
      counter: result.counter,
      hash: result.hash,
      signature: signatureB64,
      public_key: publicJwk,
      timestamp: new Date().toISOString(),
    };
  }

  async function sendVerification(payload) {
    setStatus("Sending verification...");
    const res = await fetch("/poh/verify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    });
    if (!res.ok) {
      const text = await res.text();
      throw new Error(`Verify failed: ${res.status} ${text}`);
    }
    return res.json();
  }

  async function startPoH() {
    try {
      setStatus("Requesting challenge...");
      const kp = await getOrCreateKeyPair();
      const startRes = await fetch("/poh/start");
      if (!startRes.ok) throw new Error(`Failed to start PoH: ${startRes.status}`);
      const startData = await startRes.json();
      const { challenge, difficulty } = startData;
      const powResult = await runPoW(challenge, difficulty);
      if (!powResult) return;
      setStatus("Signing proof-of-work result...");
      const signed = await signPoW(kp.privateKey, powResult, kp.publicJwk);
      const verifyRes = await sendVerification(signed);
      if (verifyRes.status === "ok") {
        setStatus("Proof-of-human completed. Redirecting to search...");
        window.location.href = "/searxng/";
      } else {
        setStatus("Verification failed.");
        console.error("Verification error", verifyRes);
      }
    } catch (e) {
      console.error("PoH error", e);
      setStatus("Error during PoH: " + (e && e.message ? e.message : String(e)));
      glowAnimating = false;
    }
  }

  function handlePointerDown(e) {
    e.preventDefault();
    if (holding) return;
    holding = true;
    startPoH().finally(() => {
      holding = false;
    });
  }

  function handlePointerUp(e) {
    e.preventDefault();
    holding = false;
  }

  holder.addEventListener("pointerdown", handlePointerDown);
  holder.addEventListener("pointerup", handlePointerUp);
  holder.addEventListener("pointercancel", handlePointerUp);
  holder.addEventListener("pointerleave", handlePointerUp);

  layoutGlow();
})();

The JS is entirely optional from the server’s perspective: any client that can solve the PoW, sign the result, and call /poh/verify with the correct payload will be accepted. The point is to make that cheap for human browsers and unattractive for bot automatons.


đź”—4. Static Assets and Favicon Behaviour

“So what’s with the favicon.ico behavior, guv?” “Well the truth is, bots don’t download favicon.ico’s because they don’t need them, humans want them.”

Yes, I am a Hitchikers Guide to the Galaxy type of person.

  • Locally stored at /opt/poh/app/static/dontpanic.png.
  • Served via GET /dontpanic.png.
  • Used as the visual “button” inside the PoH page.

đź”—4.2. Towel favicon and heuristic

  • Locally stored at /opt/poh/app/static/favicon.ico, built from a towel PNG.
  • Served by GET /favicon.ico.
  • Each favicon request records _favicon_hits[ip] = _now().
  • /poh/start looks up _favicon_hits:
    • If the IP hasn’t fetched the favicon in the last 10 minutes, the PoW difficulty is bumped by 4 bits and the session TTL is later shortened.
    • 4 bits may not sound like much, but it’s orders of magnitude harder.

This adds a low‑cost behavioural signal: most browsers fetch the favicon automatically, whereas many simple HTTP clients do not.


đź”—5. Systemd and Caddy Integration (Summary)

For quick reference:

  • PoH app

    • Code lives under /opt/poh/app.
    • venv at /opt/poh/venv.
    • Service: poh.service running uvicorn on 127.0.0.1:9000.
  • SearXNG

    • Still managed by uWSGI, now also exposing HTTP on 127.0.0.1:8888 via http-socket.
  • Caddy

    • Uses a delphi-common snippet containing:
      • Shared headers.
      • PoH cookie gating for /searxng*.
      • Reverse proxy to PoH app for everything else.
    • Two site blocks:
      • delphi.example.com (Let’s Encrypt).
      • delphi.abc123.ts.net (Tailscale cert via tailscale cert).

This keeps the routing picture simple while allowing both public DNS and Tailscale names to share the same gate and SearXNG backend.


đź”—6. Complete Config Reference

This section pulls together the key files so someone can reproduce the exact setup.

đź”—6.1. /opt/poh/app/main.py

This file is shown in full in section 2 and can be copied as‑is. It provides:

  • /poh/start and /poh/verify endpoints.
  • PoW verification and WebCrypto signature checking.
  • Session cookie issuance with TTL depending on suspicious flag.
  • HTML shell, static file routes, and favicon heuristic.

If you want to regenerate it quickly on a new host, you can copy the entire code block from section 2 into /opt/poh/app/main.py.

đź”—6.2. /opt/poh/app/static/poh.js

The full, working script is in section 3. To deploy:

mkdir -p /opt/poh/app/static
cat > /opt/poh/app/static/poh.js << 'EOF'
<paste the script from section 3 here>
EOF

đź”—6.3. /etc/systemd/system/poh.service

[Unit]
Description=Proof-of-Human FastAPI
After=network.target

[Service]
User=mjh
Group=mjh
WorkingDirectory=/opt/poh
Environment=PATH=/opt/poh/venv/bin:/usr/bin
ExecStart=/opt/poh/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 9000
Restart=always
RestartSec=2

[Install]
WantedBy=multi-user.target

Enable and start:

sudo systemctl daemon-reload
sudo systemctl enable --now poh.service

đź”—6.4. /etc/uwsgi/apps-enabled/searxng.ini (excerpt)

[uwsgi]
uid = searxng
gid = searxng

env = LANG=C.UTF-8
env = LANGUAGE=C.UTF-8
env = LC_ALL=C.UTF-8

chdir = /usr/local/searxng/searxng-src/searx
env = SEARXNG_SETTINGS_PATH=/etc/searxng/settings.yml

disable-logging = true
chmod-socket = 666
single-interpreter = true
master = true
lazy-apps = true
plugin = python3,http
enable-threads = true
workers = %k
threads = 4

module = searx.webapp
virtualenv = /usr/local/searxng/searx-pyenv
pythonpath = /usr/local/searxng/searxng-src

socket = /usr/local/searxng/run/socket
http-socket = 127.0.0.1:8888
buffer-size = 8192
offload-threads = %k

After editing:

sudo systemctl restart uwsgi

đź”—6.5. /etc/caddy/Caddyfile

# Caddyfile for SearXNG + PoH

(delphi-common) {
    encode zstd gzip
    header {
        X-Frame-Options "SAMEORIGIN"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "no-referrer-when-downgrade"
    }

    # Match SearXNG requests with PoH cookie present
    @searx_poh {
        path /searxng*
        header Cookie *poh_session=*
    }

    # Match all SearXNG requests
    @searx_all {
        path /searxng*
    }

    # Allow SearXNG only when PoH cookie exists
    handle @searx_poh {
        reverse_proxy http://127.0.0.1:8888 {
            header_up Host {host}
            header_up X-Forwarded-Proto {scheme}
            header_up X-Script-Name /searxng
            header_up X-Real-IP {remote}
            header_up X-Forwarded-For {remote_host}
        }
    }

    # Otherwise, bounce back to the PoH gate
    handle @searx_all {
        redir / 302
    }

    # Everything else (/, /poh, JS, images, favicon, etc.) goes to PoH app
    handle {
        reverse_proxy http://127.0.0.1:9000
    }
}


delphi.example.com {
    import delphi-common
}

delphi.abc123.ts.net {
    tls /etc/caddy/delphi-tail.crt /etc/caddy/delphi-tail.key
    import delphi-common
}

Reload Caddy:

sudo systemctl restart caddy

đź”—6.6. Tailscale certificate commands

On the Delphi host (already joined to your Tailscale tailnet):

sudo tailscale cert delphi.abc123.ts.net

sudo mv delphi.abc123.ts.net.crt /etc/caddy/delphi-tail.crt
sudo mv delphi.abc123.ts.net.key /etc/caddy/delphi-tail.key
sudo chown caddy:caddy /etc/caddy/delphi-tail.*
sudo chmod 600 /etc/caddy/delphi-tail.key

Then restart Caddy as shown above.

With these concrete configs in place, a second environment should be able to reproduce what Delphi is currently running: PoH/PoW gate, favicon‑aware difficulty, cookie‑gated SearXNG, and dual hostnames (public + Tailscale) sharing the same routes. If you don’t intend public access, you can now remove the Caddyfile entry for the public host and just use Tailscale.