🔗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: 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: 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: 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 };
}
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;
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.”
🔗4.1. Don’t Panic logo
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
(delphi-common) {
encode zstd gzip
header {
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer-when-downgrade"
}
@searx_poh {
path /searxng*
header Cookie *poh_session=*
}
@searx_all {
path /searxng*
}
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}
}
}
handle @searx_all {
redir / 302
}
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.