Sish Installation

Private InstallOverall Install / No BridgeTabbbbbbbbb

Localhost.run

ssh -R shawneee-mini.com:80:127.0.0.1:1234 plan@localhost.run
ssh -R 80:localhost:8080 nokey@localhost.run

Sish Private Relay

A) open 2222 on the router so that it can be gotten to on .26

service nameexternal portinternal portinternal ip address
sish-tunnel22222222192.168.1.26

Sish setup

1. Confirm architecture first (.26) (don't guess — this determines which binary to grab):

bash

uname -m

x86_64 → amd64 build. aarch64/arm64 → arm64 build. (Given .26 has been running general-purpose Linux services this whole time, x86_64 is likely, but confirm rather than assume — exactly the kind of check that avoids a wasted download.)

2. Install sish (.26) (adjust filename to match step 1's result):

bash

cd /tmp
# for x86_64:
cd /tmp
rm -f sish_linux_amd64.tar.gz   # clear out the bad 9-byte file first
curl -LO https://github.com/antoniomika/sish/releases/download/v2.23.0/sish-2.23.0.linux-amd64.tar.gz
tar xzf sish-2.23.0.linux-amd64.tar.gz
ls

confirm -

ls sish-2.23.0.linux-amd64/

single binary install it

all ok then

sudo mv sish-2.23.0.linux-amd64/sish /usr/local/bin/sish sudo chmod +x /usr/local/bin/sish sish --version

Quick heads up before you get to step 2's last line: sish --version sometimes exits non-zero on binaries that only support --help for version info, so if sish --version errors out instead of printing a string, try sish --help | head -5 instead — that'll still confirm the binary runs even if --version isn't wired up the way you'd expect.

keep it tidy

rm -rf /tmp/sish-2.23.0.linux-amd64 /tmp/sish-2.23.0.linux-amd64.tar.gz

3. Create the service user + dirs (.26):

bash

sudo useradd -r -d /etc/sish -s /sbin/nologin sish
sudo mkdir -p /etc/sish/keys /etc/sish/pubkeys
sudo chown -R sish:sish /etc/sish

4. Create / Add your public key on the razer (.21) (from wherever you'll run the tunnel command — same place LM Studio is runing: if running on a mac

ssh-keygen -t ed25519 -f ~/.ssh/sish_ed25519 -C "sish tunnel key"

on running on a pc

ssh-keygen -t ed25519 -f $env:USERPROFILE\.ssh\sish_ed25519 -C "sish tunnel key"

This creates ~/.ssh/sish_ed25519 (private) and ~/.ssh/sish_ed25519.pub (public). Good instinct to use a dedicated key here rather than your general SSH identity — keeps this tunnel-only key revocable independently if it ever needs to be pulled, without touching your regular SSH access anywhere else.

4a. Copy the pub for the key over to .26 machine – (where sish runs and checks incoming keys):

scp $env:USERPROFILE\.ssh\sish_ed25519.pub spiffy-root@192.168.1.26:/tmp/sish_ed25519.pub

then move it

[spiffy-root@proxy tmp]$ sudo mv sish_ed25519.pub /etc/sish/pubkeys/sish_ed25519.pub [sudo] password for spiffy-root: [spiffy-root@proxy tmp]$ cd /etc/sish/pubkeys/ [spiffy-root@proxy pubkeys]$ ls sish_ed25519.pub [spiffy-root@proxy pubkeys]$

check ownership ls -la /etc/sish/pubkeys/sish_ed25519.pub id sish


!!!!!!!!!!!!!to fix - or run anyway since we need it for the sish/keys directory anyway

sudo useradd -r -d /etc/sish -s /sbin/nologin sish sudo mkdir -p /etc/sish/keys /etc/sish/pubkeys sudo chown -R sish:sish /etc/sish

bash

# on your Mac:
cat ~/.ssh/sish_ed25519.pub
# copy the output, then on .26:
sudo nano /etc/sish/pubkeys/sish_ed25519.pub
# paste it, save
sudo chown sish:sish /etc/sish/pubkeys/sish_ed25519.pub
# on a PC
# ===== 4a. Drop pubkey in (.26) =====
sudo nano /etc/sish/pubkeys/sish_ed25519.pub
# paste, save
sudo chown sish:sish /etc/sish/pubkeys/sish_ed25519.pub

#Via PowerShellOpen PowerShell and run: Get-Content ~/.ssh/sish_ed25519.pub | Set-Clipboard

(replace id_rsa.pub with your actual public key file name if different, such as id_ed25519.pub).

#Via Command PromptOpen Command Prompt and run: clip < %USERPROFILE%.ssh\sish_ed25519.pub

#Via Git BashOpen Git Bash and run: cat ~/.ssh/sish_ed25519.pub | clip

5. systemd unit (.26):

bash

sudo nano /etc/systemd/system/sish.service

Add the sish.service file

[Unit]
Description=sish - self-hosted SSH tunnel broker (replaces localhost.run)
After=network.target
[Service]
Type=simple
User=sish
Group=sish
ExecStart=/usr/local/bin/sish \
  --ssh-address=:2222 \
  --http-address=127.0.0.1:8081 \
  --https=false \
  --authentication-keys-directory=/etc/sish/pubkeys \
  --private-keys-directory=/etc/sish/keys \
  --bind-random-ports=false \
  --domain=shawns-machine.com
Restart=on-failure
RestartSec=5

[Install] WantedBy=multi-user.target

The load and check it:

bash

sudo systemctl daemon-reload
sudo systemctl enable --now sish
sudo systemctl status sish

Confirm it says active (running) before moving on.

6. nginx conf — paste the regex-based router conf into /etc/nginx/conf.d/sish-router.shawns-machine.com.conf

# Routes any {name}.aitunnel.shawns-machine.com to sish, which multiplexes
# by Host header to whichever SSH tunnel registered that name. sish does
# NOT terminate its own TLS here — nginx does (matches the existing
# Apache:8090-behind-nginx pattern on .9), so sish's http port stays on
# loopback only, never exposed directly. NOTE: 8081, not 8080 — 8080 is
# already httpd on this box.
server {
    listen 443 ssl http2;
    server_name *.aitunnel.shawns-machine.com;
ssl_certificate /etc/letsencrypt/live/aitunnel.shawns-machine.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/aitunnel.shawns-machine.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

location / {
if ($http_x_mesh_key != "PASTE_LONG_RANDOM") { return 403; } # this is the auth string created by openssl rand -hex 32
    proxy_pass http://127.0.0.1:8081;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 120s;
}

} server { listen 80; server_name *.aitunnel.shawns-machine.com; return 301 https://$host$request_uri; }

openssl rand -hex 32 for the value. Leave the rest of the block alone — proxy_http_version 1.1, the Upgrade/Connection pair, and proxy_read_timeout 120s are what keep streaming completions alive, and clobbering them fails mid-generation rather than at connect.

then:

bash

 sudo nginx -t && sudo systemctl reload nginx

curl -s -o /dev/null -w "%{http_code}\n" https://smpt1.aitunnel.shawns-machine.com/v1/models

want 403

curl -s -o /dev/null -w "%{http_code}\n" -H "X-Mesh-Key: YOURKEY" \ https://smpt1.aitunnel.shawns-machine.com/v1/models

want 200

headers: { 'X-Mesh-Key': 'LONG_RANDOM_STRING' }

Paste me that output before reloading — same checkpoint discipline as before.

7. If clean:

bash

sudo systemctl reload nginx

8. Firewall check — confirm 2222 is actually reachable from outside before testing (this is the step most likely to silently block you):

bash

sudo firewall-cmd --list-ports    # if firewalld
# or
sudo iptables -L -n | grep 2222   # if raw iptables

If 2222 isn't open: sudo firewall-cmd --permanent --add-port=2222/tcp && sudo firewall-cmd --reload

9. Test — run from your Razer – same machine as LM Studio, or explicit IP. LMStudio is installed, against the throwaway test subdomain, not the live one:

ssh -i ~/.ssh/sish_ed25519 -p 2222 -R test-aitunnel:80:192.168.1.21:1234 -N tunnel@shawns-machine.com

Everything downstream (the systemd unit, nginx conf, test command) is unaffected by the key's name — only the file path in step 4 changes. When you get to the actual tunnel command, point -i at the private key explicitly, since it's not your default identity:

1. Yes — Run it directly on the Razer PC (192.168.1.21), where localhost:1234 correctly points at LM Studio itself. If you ever run the tunnel command from a different LAN machine, swap localhost for 192.168.1.21 explicitly — same distinction as before.

2. The username is not a real account on any machine — it doesn't need to exist anywhere. This is the key fact: sish is its own SSH server implementation. It intercepts the SSH handshake, checks the presented public key against files in /etc/sish/pubkeys/, and that's the entire auth decision. It never consults /etc/passwd, never asks .9 or .26's actual OS for a user named "shawn," "sish," or anything else. The username string in user@host is just a label sish's SSH layer sees — in this case it's essentially unused since we're explicitly naming the tunnel (test-aitunnel:80:...) rather than asking sish to auto-generate a subdomain from the username.

So it can be anything — shawn@, sish@, tunnel@, doesn't matter, and it doesn't need to match the key filename either (sish_ed25519 was just what we called the key file on disk; unrelated). I'd pick something that won't confuse future-you into thinking it's a real account — tunnel@shawns-machine.com reads clearest for that reason, but it's a naming choice, not a technical requirement.

On the two versions you pasted back — the one without -i only works if the key is either your default identity (~/.ssh/id_ed25519,

ssh -R test-aitunnel:80:localhost:1234 -p 2222 -N shawn@shawns-machine.com

which sish tries automatically) or already loaded in ssh-agent.

Since you deliberately named it sish_ed25519 (non-default), you do still need -i unless you add it to ssh-agent (ssh-add ~/.ssh/sish_ed25519) or drop a Host entry in ~/.ssh/config. Simplest for a one-off test — keep -i explicit:

bash

ssh -i ~/.ssh/sish_ed25519 -p 2222 -R test-aitunnel:80:192.168.1.21:1234 -N tunnel@shawns-machine.com
  1. While that's running, from your phone on cellular:

https://test-aitunnel.shawns-machine.com/v1/models

Same three models = the whole chain (SSH → sish → nginx → public HTTPS) is proven.

Once that works, you've validated the pattern SPI-516/517 depend on — want me to log the completed setup + test result to SPI-517 once you confirm step 10?

Same as any header. The endpoint is unchanged — you just add one line.

curl

bash

curl -s https://smpt1.aitunnel.shawns-machine.com/v1/chat/completions \
  -H "X-Mesh-Key: YOURKEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"qwen3-coder-30b-a3b-instruct",
       "messages":[{"role":"user","content":"hello"}],
       "max_tokens":200}'

PHP — accid-llm.php

php

curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: application/json',
    'X-Mesh-Key: ' . getenv('MESH_KEY'),
]);

JS — Extendy

js

headers: {
  'Content-Type': 'application/json',
  'X-Mesh-Key': MESH_KEY
}

Python / OpenAI SDK — it wants default_headers, not api_key:

python

client = OpenAI(
    base_url="https://smpt1.aitunnel.shawns-machine.com/v1",
    api_key="unused",
    default_headers={"X-Mesh-Key": os.environ["MESH_KEY"]},
)

api_key is required by the SDK but ignored by nginx — that check reads X-Mesh-Key only.

Where to keep it

On machines you control, a file plus an env var:

bash

echo 'YOURKEY' > ~/.mesh-key && chmod 600 ~/.mesh-key
export MESH_KEY=$(cat ~/.mesh-key)     # into .zshrc / .bashrc

For accid-llm.php, an env var on .9 rather than a constant in the file — a hardcoded key gets copied along with the file into every project's embeds/, which is the fan-out problem showing up as a security problem.

Two things that will bite

Extendy ships the key to the browser. A Chrome extension's source is readable, so anyone with the extension has the key. Fine while it's only you. The moment it goes to someone else, that caller needs its own key you can revoke — or it needs to go through a server instead.

Streaming is the test that matters. A short non-streaming call proves the header works but not much else. Run one real "stream": true completion before you call it done; that's what exercises the Upgrade/Connection headers and the 120s timeout in the sish router block.

Good consolidation — and good thing we're doing one final pass, because there's a real bug in it: step 6's nginx conf still has proxy_pass http://127.0.0.1:8080 — that's the old port, the one that turned out to belong to httpd. If you ran this as pasted, nginx would route tunnel traffic straight into Apache instead of sish, and you'd get either a confusing wrong-content response or a 404 — not an obvious "wrong port" error, which makes it the nastiest kind of bug to chase after the fact. Needs to be 8081 to match the corrected systemd unit from step 5. One more small inconsistency, cosmetic but worth fixing: step 4a's Windows block references id_rsa.pub (a generic example) instead of your actual sish_ed25519.pub — copy-paste residue from wherever that snippet came from. Corrected below. Here's the clean, fully consistent version, front to back:

&num;===== 1. Confirm architecture (.26) =====
uname -m

# x86_64 confirmed already

&num;===== 2. Install sish (.26) =====

cd /tmp
rm -f sish_linux_amd64.tar.gz   # clear out the bad 9-byte file first
curl -LO https://github.com/antoniomika/sish/releases/download/v2.23.0/sish-2.23.0.linux-amd64.tar.gz
tar xzf sish-2.23.0.linux-amd64.tar.gz
ls

&num;all ok then

sudo mv sish /usr/local/bin/sish
sudo chmod +x /usr/local/bin/sish
sish --version

&num;===== 3. Service user + dirs (.26) =====

sudo useradd -r -d /etc/sish -s /sbin/nologin sish
sudo mkdir -p /etc/sish/keys /etc/sish/pubkeys
sudo chown -R sish:sish /etc/sish

&num;===== 4. Generate keypair (Razer PC, PowerShell) =====

ssh-keygen -t ed25519 -f $env:USERPROFILE\.ssh\sish_ed25519 -C &quot;sish tunnel key&quot;

Get the public key to copy over:

Get-Content $env:USERPROFILE\.ssh\sish_ed25519.pub | Set-Clipboard

&num;===== 4a. Drop pubkey in (.26) =====

sudo nano /etc/sish/pubkeys/sish_ed25519.pub

&num;paste, save

sudo chown sish:sish /etc/sish/pubkeys/sish_ed25519.pub

&num;===== 5. systemd unit (.26) =====

sudo nano /etc/systemd/system/sish.service

[Unit]
Description=sish - self-hosted SSH tunnel broker (replaces localhost.run)
After=network.target

[Service]
Type=simple
User=sish
Group=sish
ExecStart=/usr/local/bin/sish \
  --ssh-address=:2222 \
  --http-address=127.0.0.1:8081 \
  --https=false \
  --authentication-keys-directory=/etc/sish/pubkeys \
  --private-keys-directory=/etc/sish/keys \
  --bind-random-ports=false \
  --domain=shawns-machine.com
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now sish
sudo systemctl status sish

&num;confirm: active (running)

&num;===== 6. nginx conf (.26) — CORRECTED to 8081 =====

sudo nano /etc/nginx/conf.d/sish-router.shawns-machine.com.conf

Routes any {name}.aitunnel.shawns-machine.com to sish, which multiplexes

by Host header to whichever SSH tunnel registered that name. sish does

NOT terminate its own TLS here — nginx does (matches the existing

Apache:8090-behind-nginx pattern on .9), so sish's http port stays on

loopback only, never exposed directly. NOTE: 8081, not 8080 — 8080 is

already httpd on this box.

server {
    listen 443 ssl http2;
    server_name *.aitunnel.shawns-machine.com;
    ssl_certificate /etc/letsencrypt/live/aitunnel.shawns-machine.com/fullchain.pem;
		ssl_certificate_key /etc/letsencrypt/live/aitunnel.shawns-machine.com/privkey.pem;
		include /etc/letsencrypt/options-ssl-nginx.conf;
		ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
		location / {
    if ($http_x_mesh_key != "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX") { return 403; }  &num;this is the 		auth string created by openssl rand -hex 32
		proxy_pass http://127.0.0.1:8081;
		proxy_http_version 1.1;
 		proxy_set_header Host $host;
		proxy_set_header X-Real-IP $remote_addr;
		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
		proxy_set_header X-Forwarded-Proto $scheme;
		proxy_set_header Upgrade $http_upgrade;
		proxy_set_header Connection "upgrade";
		proxy_read_timeout 120s;
    }
}
server {
	listen 80;
	server_name *.aitunnel.shawns-machine.com;
	return 301 https://$host$request_uri;
}

&num;THEN:

sudo nginx -t && sudo systemctl reload nginx
curl -s -o /dev/null -w "%{http_code}\n" https://smpt1.aitunnel.shawns-machine.com/v1/models

&num;want 403

curl -s -o /dev/null -w "%{http_code}\n" -H "X-Mesh-Key: YOURKEY" \
     https://smpt1.aitunnel.shawns-machine.com/v1/models

&num;want 200


&num;===== 7. add key secrets file and call to key to approriate headers  =====

Paste that output here before reloading.

# ===== 7. If clean =====
sudo systemctl reload nginx
# ===== 8. Firewall — already confirmed done, 2222/tcp is open =====
sudo firewall-cmd --list-ports
# should show: 80/tcp 443/tcp 2222/tcp 808o/tcp - 8081 is a loopback so does not need to be opened in firewall
# ===== 9. Test (Razer PC, PowerShell) =====
ssh -i $env:USERPROFILE\.ssh\sish_ed25519 -p 2222 -R test-aitunnel:80:localhost:1234 -N tunnel@shawns-machine.com

(localhost:1234 is correct here since this runs on the Razer PC, where LM Studio actually listens — no need for the LAN IP when you're already on the box.)

# ===== 10. From your phone, cellular data =====
https://test-aitunnel.shawns-machine.com/v1/models

Same three models = fully proven, end to end.

Go ahead and run 1-6, paste me the nginx -t output at the checkpoint before reloading.


accid-llm – Setting up llm on as standalone

A standalone server-side relay to an OpenAI-compatible chat endpoint.

It exists because a browser cannot hold a tunnel credential. Extension source is readable, so anything shipped to the client is public. This file sits on a server, holds the real credential, and hands out a revocable one instead.

Nothing here knows about ACCID. Drop the folder anywhere PHP runs.


Files

FileSecretPurpose
llm.phpnoThe relay. The only executable file.
llm-config.jsonnoEndpoint, model, call parameters.
llm-secrets.jsonyescaller_token and auth_value. Not in version control.
.htaccessnoDenies .json and .md regardless of the parent install.

llm-secrets.json is merged over llm-config.json, so a shared key in secrets wins. That lets the endpoint live in git while the credential does not.

llm.php is the only executable one, and it knows nothing about ACCID — no dev_project, no bridge paths.

Design points worth flagging:

The .htaccess is self-protecting. It denies .json inside the folder regardless of what the surrounding install does. That's what makes the package droppable somewhere without your htaccess layers.

Config and secrets are separate, and secrets override. Endpoint and model can live in version control; caller_token and auth_value can't. Same merge trick means one file to gitignore.

auth_header is a config field, not a hardcoded string. Yours is X-Mesh-Key. localhost.run needs none. Cloudflare Access uses two different ones. The packet doesn't care.

ai_tunnel_url is gone. That's the one that would have handed your key to whoever asked.

The realpath guard at the bottom lets the same file work as a direct POST endpoint and as a require_once from the bridge. Both entry points, no duplication..


Two credentials, and why

caller  --caller_token-->  llm.php  --auth_value-->  tunnel  -->  LM Studio
  • caller_token is presented by callers to llm.php. Ships in the extension, so treat it as semi-public. Its value is that it is revocable and per-caller: rotate it and only that caller breaks.
  • auth_value is presented by llm.php to the tunnel. Never reaches a browser. This is the one that must not leak.

Generate both with openssl rand -hex 32. Do not reuse one for the other.


Setup A — self-hosted tunnel (sish behind nginx)

The package needs its own caller token. Two tiers:

extension / caller  --caller_token-->  llm.php  --X-Mesh-Key-->  tunnel  -->  LM Studio
                    (revocable, per-user)        (never leaves the server)

That's also what makes it work for someone else: they set a caller token and a tunnel URL, and nothing else about their setup has to match yours

For a machine that cannot accept inbound connections. The model box dials out.

llm-config.json:

json

{
  "transport": "relay",
  "endpoint": "https://smpt1.aitunnel.shawns-machine.com/v1/chat/completions",
  "auth_header": "X-Mesh-Key"
}

llm-secrets.json:

json

{
  "caller_token": "<openssl rand -hex 32>",
  "auth_value":   "<the X-Mesh-Key value from the nginx conf>"
}

auth_value must match the string in the nginx map or if exactly. The comparison is case-sensitive and a trailing space inside the quotes in nginx.conf is the usual cause of a 403 with a key that looks correct.

Yes — that's exactly the shape, and no curl after setup.

Goggles  ──POST──▶  accid-bridge.php  ──require──▶  accid-private-llm.php  ──X-Mesh-Key──▶  tunnel
 (no key)            (checks passcode)              (attaches key here)

The key is read from llm-secrets.json at call time, server-side, and attached to the outgoing request. It never travels to the browser and never appears in devtools. What the extension sends is the endpoint name, not the URL — so switching between sish and lhr is a dropdown, and neither choice can redirect the key.

What goes in Goggles settings

bridge URL     https://starter.shawns-machine.com/trippy/tomakeseed/zapps/accid-bridge.php
dev_project    tomakeseed
hash           <project passcode hash>
endpoint       sish
model          qwen3-coder-30b-a3b-instruct

Five fields, no secrets among them that the tunnel cares about.

The hash is the one to think about. On an ACCID page in edit mode it's already sitting at window.ACCID_AUTH_HASH, so Goggles can read it. But on any other site there's no ACCID page to read it from, so it has to be stored in extension settings — and that hash is the project passcode. Anything holding it can also call write_perspective and delete-perspective on that tenant.

For you alone that's fine. It does mean Goggles is holding edit rights to the site, not just chat rights, which is worth knowing before it ever goes to anyone else. If that becomes a problem the fix is a second credential scoped to ask_llm only — but that's a later change, not something to build now.

Send the Goggles files when you want the settings panel and the fetch wired up.


Setup B — localhost.run

No reverse proxy, no domain, no certificates, no port forwarding. On the machine running LM Studio:

bash

ssh -R 80:localhost:1234 nokey@localhost.run

It prints a public HTTPS URL. Put that URL in endpoint with /v1/chat/completions appended, and leave auth_header empty:

json

{
  "transport": "relay",
  "endpoint": "https://<assigned>.lhr.life/v1/chat/completions",
  "auth_header": ""
}

caller_token still applies and still matters — it is the only thing standing between this relay and anyone who finds the URL.

Tradeoff worth knowing: a third-party tunnel terminates TLS on someone else's server, so they can see the traffic in cleartext at that point. Fine for asking a local model about a web page. Not fine for anything sensitive. That is the point at which self-hosting starts to be worth the trouble.

Note the URL changes on each reconnect unless you have a paid plan with a reserved subdomain.

The asymmetry is exactly the point. There's nothing to steal by redirecting a request that carries no credential.

So the rule isn't "trust the caller more," it's a caller-supplied URL never gets a credential attached. That holds regardless of who's calling:

endpoint (a name)  →  config lookup  →  may attach the mesh key
endpoint_url       →  used as given  →  never attaches anything

accid-private-llm.php keeps refusing URLs because it has a key to lose. llm.php can accept one, because on the localhost.run path there is no key.

It's also the better shape operationally: a free localhost.run URL changes on every reconnect, so a user editing a JSON file each morning is worse than the extension holding it and sending it per request.

llm.php now takes endpoint_url from the caller. Three things it does with it:

Never attaches a credential to it. The auth-header block is wrapped in if (!$caller_chosen). That's the whole safety argument, and it's why the old ai_tunnel_url hole can't reopen through this door.

Withholds the raw echo. For a configured endpoint, upstream error bodies come back so you can debug. For a caller-supplied URL they don't — otherwise someone could point the relay at an internal address and read the reply, turning it into a blind fetch oracle. That's the one real risk left once the credential is off the table, and suppressing the echo closes it.

Reports which path ran — the response carries endpoint: "caller-supplied" or "configured", so a stale URL in extension settings is visible rather than mysterious.


Private / Local — how they diverge

 privatellm.php
gatebridge passcodecaller_token
endpointname onlyname, or caller URL
credentialmesh key attachedonly for configured
who it's foryoulocalhost.run users

Same folder, same llm-config.json, same llm-secrets.json, same .htaccess.

For a localhost.run user the setup is: run the ssh -R, paste the URL into Goggles, set a caller_token. No config editing, and when the URL changes on reconnect they paste the new one.

One thing to decide when you wire Goggles: whether the URL field is per-request or remembered. Remembered is friendlier, but a stale URL after a reconnect gives Upstream unreachable with no hint it's stale. Stamping the URL next to that error in the UI is a small touch that saves the obvious support question.


Setup C — same machine

If llm.php runs on the same box as LM Studio, no tunnel is involved:

json

{
  "transport": "local",
  "endpoint": "http://127.0.0.1:1234/v1/chat/completions",
  "auth_header": ""
}

If a browser extension is also on that machine, consider skipping this relay entirely and calling LM Studio direct from the extension service worker. That needs "host_permissions": ["http://127.0.0.1/*"] and LM Studio's CORS toggle. Chrome has been tightening Local Network Access, so test that path before depending on it.


Calling it

curl

bash

curl -s https://smpt1.aitunnel.shawns-machine.com/v1/chat/completions \
  -H "X-Mesh-Key: YOURKEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"qwen3-coder-30b-a3b-instruct",
       "messages":[{"role":"user","content":"hello"}],
       "max_tokens":200}'

PHP — accid-llm.php

php

curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: application/json',
    'X-Mesh-Key: ' . getenv('MESH_KEY'),
]);

JS — Extendy

js

headers: {
  'Content-Type': 'application/json',
  'X-Mesh-Key': MESH_KEY
}

Python / OpenAI SDK — it wants default_headers, not api_key:

python

client = OpenAI(
    base_url="https://smpt1.aitunnel.shawns-machine.com/v1",
    api_key="unused",
    default_headers={"X-Mesh-Key": os.environ["MESH_KEY"]},
)

api_key is required by the SDK but ignored by nginx — that check reads X-Mesh-Key only.

Where to keep it

On machines you control, a file plus an env var:

bash

echo 'YOURKEY' > ~/.mesh-key && chmod 600 ~/.mesh-key
export MESH_KEY=$(cat ~/.mesh-key)     # into .zshrc / .bashrc

For accid-llm.php, an env var on .9 rather than a constant in the file — a hardcoded key gets copied along with the file into every project's embeds/, which is the fan-out problem showing up as a security problem.

Two things that will bite

Extendy ships the key to the browser. A Chrome extension's source is readable, so anyone with the extension has the key. Fine while it's only you. The moment it goes to someone else, that caller needs its own key you can revoke — or it needs to go through a server instead.

Streaming is the test that matters. A short non-streaming call proves the header works but not much else. Run one real "stream": true completion before you call it done; that's what exercises the Upgrade/Connection headers and the 120s timeout in the sish router block.

bash

curl -s https://your-host/accid-llm/llm.php \
  -F "caller_token=YOUR_CALLER_TOKEN" \
  -F "prompt=Explain this function" \
  -F "system=You are terse."

Multi-turn and tool-call loops use messages_json instead, which wins if both are supplied:

bash

curl -s https://your-host/accid-llm/llm.php \
  -F "caller_token=YOUR_CALLER_TOKEN" \
  -F 'messages_json=[{"role":"user","content":"hello"}]'

From an extension service worker:

js

const form = new FormData();
form.append("caller_token", CALLER_TOKEN);
form.append("messages_json", JSON.stringify(messages));
const r = await fetch(RELAY_URL, { method: "POST", body: form });
const data = await r.json();

Responses are always JSON:

success  { ok: true,  response: "...", model: "..." }
failure  { ok: false, error: "...", raw?: "..." }

Hosting it inside ACCID

Keep a thin dispatch in accid-bridge.php so existing installs do not break:

php

if ($action === 'ask_llm') {
    require_once __DIR__ . '/accid-llm/llm.php';
    accid_llm_handle();
    exit;
}

The realpath guard at the bottom of llm.php means the direct-invocation block does not fire when the file is required, so both entry points work.

Two things to note about doing it this way:

  • The bridge's require_project_auth() gate runs first, so the project passcode applies in addition to caller_token. That is fine and is strictly safer than either alone.
  • Do not copy this folder on export. It is opt-in. An export that carries a populated llm-secrets.json ships your credential to whoever receives it.

Error reference

ErrorMeans
Invalid or missing caller token (403)Caller's caller_token is wrong.
Tunnel rejected this relay credential (502)auth_value is wrong, or the header name does not match the server's. Different problem from the row above.
Upstream unreachable (502)Tunnel down, model box asleep, or the SSH session dropped.
Upstream returned HTTP 404endpoint is missing /v1/chat/completions.
Upstream response had no message contentReached a server, but not an OpenAI-compatible one. Check raw.

Deliberately not supported

ai_tunnel_url as a request field. An earlier version let callers name the destination. That meant any caller could point the relay at their own server and receive auth_value in the outgoing header. The endpoint is config-only. Do not reintroduce it.

Streaming. Responses are buffered. Streaming needs "stream": true passed through plus SSE relaying, and any reverse proxy in the path needs proxy_buffering off. Worth adding, but it is a separate change.

Check complete. The manifests are unnecessary — the data is already in memory.

Who writes manifests: nobody. Across all 54 live scripts, catalogue-module.js is the sole consumer and there is no producer. The complete set of bridge actions any live script POSTs:

create_article  ftp-status  get-media  merge_dropper
save-api-key    write_config  write_perspective

No manifest build action exists. So the writer is builder-side, server-side-but-uncalled, or a script Codey runs — it is not part of the served site.

The real finding is 24 lines above the fetch. Catalogue has two branches:

if (source === 'pov') {
  // no manifests, no cascade, nothing bridge-side (by design; see accid-pov).
  const idx = window.ACCID_PERSPECTIVES || [];      // filter in memory
} else {
  fetch(`${dev_project}-accid/manifests/${prefix}-${term}.json`)   // 404
}

POV was migrated to label-and-lens. Category and tag were left behind on the old manifest path. Both doctrines are living in the same function, and the comment declaring "no manifests" sits directly above the manifest fetch.

It fails silently. A 404 doesn't throw, so the catch never fires and the console.warn never prints. resp.ok is false, nothing gets pushed, allPosts stays empty. Every /category/{term}/ and /tag/{term}/ catalogue renders an empty grid with zero diagnostics. /pov/{term}/ works fine. That asymmetry is your fingerprint.

And the index already has everything the manifest has. From the live 93KB index, on the exact fixture in your paste:

categories   ['cat-gamma']                                    145/145 perspectives
tags         ['tag-green', 'tag-loud']                        145/145
povs         ['pov-side']                                     145/145
infoTerms    ['cat-gamma','tag-green','tag-loud','pov-side']  145/145
excerpt, post_date, cloudUrl                                  145/145

Full coverage, no gaps, no legacy singular variants. The manifest is a redundant on-disk copy of data the loader already fetched at boot.

The fix

Delete the else branch and generalize the POV branch. It's already written — swap p.povs for a read against infoTerms, which carries all three prefixed label types in one flat array:

const idx = window.ACCID_PERSPECTIVES || [];
const terms = requested;                          // cat-gamma / tag-green / pov-side
idx.forEach(p => {
  if (window.AccidPovTerm?.isPlumbing(p)) return;
  const labels = p.infoTerms || [];
  if (terms.some(t => labels.includes(t))) allPosts.push({ ... });
});

One code path, three sources, no fetch, no manifests/ directory, no build step, no sync gap. It also kills the leading-slash url problem, because you stop consuming the manifest's url field entirely and build hrefs the same way the POV branch does.

Confidence: high. Field coverage is 145/145, the branch you'd keep is already shipping and working, and the branch you'd delete is currently returning nothing. The only thing to confirm before Codey runs is the prefix convention — the manifest writer used cat- while seg in the pill href uses category, so make sure the term strings the catalogue receives are the prefixed labels (cat-gamma) and not the bare ones (gamma).

One aside for Codey's pass: module-loader.js has emoji in its console output (lines 34, 43). Worth stripping while you're in there.

actually this is a broader question- 

why sish for {userinput}.aitunnel.shawns-machine.com

and just a regular subdomain for the sites on {dev_project}.shawns-machine.com

The distinction is who dials whom

That's the whole thing. Everything else follows from it.

Sites — the origin is stationary. Files on a disk you own, on a box that's always on, reachable from nginx. nginx dials in: serve from disk, or proxy_pass to a local port. The route never changes, so it's static config. Adding a site is adding a directory; the wildcard vhost already matches.

AI tunnel — the origin is LM Studio on the Razer box. That machine sleeps, roams networks, and sits behind NAT you don't control. nginx can't dial in because there's nothing stable to dial. So the origin dials out, and sish holds the inbound SSH and maps a hostname onto whatever just connected.

sish is solving NAT traversal and dynamic origin registration. Neither problem exists for the sites, which is why paying for sish there would be pure overhead.

Three consequences worth naming:

  • Who claims the name. ssh -R smpt1:80:localhost:1234 lets the client name its own subdomain at connect time. No server-side edit, no reload, by a process that shows up at an unpredictable moment.
  • Lifetime. A tunnel should vanish when the SSH session drops. An nginx upstream pointed at a dead host doesn't vanish — it 502s until you edit config.
  • Trust boundary. Claiming a tunnel subdomain requires an SSH key, not nginx config access.

Two live facts that bear on this

Your LAN path is down right now:

502  aitunnel.shawns-machine.com              <- direct nginx proxy_pass
200  smpt1.aitunnel.shawns-machine.com        <- sish wildcard

That's exactly the failure mode above — the static upstream outliving its backend. The sish path is the one currently carrying traffic.

Certs appear to be auto-issued, not manual:

starter.shawns-machine.com   CN=*.shawns-machine.com           notBefore Aug 17 09:01  notAfter Sep 16
smpt1.aitunnel...            CN=*.aitunnel.shawns-machine.com  notBefore Aug 17 09:01  notAfter Sep 16

All four minted this morning, 30-day validity, and each SAN lists only its own single hostname despite the wildcard CN. That's on-demand TLS, not a static wildcard file. Worth verifying before your next scheduled renewal — if something is already issuing these automatically, part of the SPI-543 through SPI-548 cadence may be a chore you no longer owe.

The cost you're paying for the nested namespace

DNS wildcards match exactly one label. *.shawns-machine.com does not cover smpt1.aitunnel.shawns-machine.com — that's two labels deep. That is the entire reason a second cert scope exists.

Flatten the namespace and it collapses:

smpt1.aitunnel.shawns-machine.com     two labels  →  needs *.aitunnel.shawns-machine.com
smpt1-ai.shawns-machine.com           one label   →  covered by *.shawns-machine.com

sish doesn't care about the shape of the name you claim. One cert scope, one renewal path, one wildcard DNS record. You lose the visual grouping that aitunnel. gives you, and gain a suffix convention instead.

When sish stops being worth it

SituationRight tool
LM Studio always on the LAN, one endpointdirect proxy_pass — you already proved this works
Machine roams, sleeps, or sits behind NAT you don't ownsish
Multiple ephemeral origins claiming their own namessish
Handing tunnel access to someone without nginx config rightssish

If the Razer box genuinely never leaves the LAN, you're running an extra daemon, an extra cert scope, and the --bind-random-subdomains=false gotcha for a problem you don't have. If it roams at all, sish is the clean answer and the 502 above is your argument for retiring the direct path rather than keeping both.

The thing I'd actually decide first: are you keeping both paths? Two routes to one model is two failure modes and two things to reason about when the answer comes back wrong — and one of them is failing right now.

well hmm. OK I see that - but 

afraid.org matches up my outer ip and "stabilizes it" 
insdie the network I froze the razors ip and lM studio lets you pick the port I just left it at 1234 it won't change if we do change it- 

I know it is safer this way and it works  was just hcekdfing

reasoning is sound, and the "safer" instinct is right — but for a narrower reason than it might seem, and there's a gap it doesn't cover.

What sish actually buys you with a frozen IP. Not NAT traversal — you've engineered that away. The real win is that LM Studio can bind to loopback only. With proxy_pass, nginx has to reach 192.168.x.x:1234, which means LM Studio is listening on the LAN interface with no auth, and anything on your network can hit it. With ssh -R, it listens on 127.0.0.1 and the only way in is through a tunnel that requires a key. That's a genuine reduction in exposed surface, and it holds even though the IP never moves.

The stability argument is also softer than it looks on a laptop specifically — sleep, a router reboot dropping the reservation, or Windows flipping the firewall profile from Private to Public on a network change will all break a frozen IP. The 502 on aitunnel.shawns-machine.com right now is that class of failure. sish reconnects; a static upstream doesn't.

The gap

sish protects publishing. It does not protect consuming. From this sandbox, no credentials:

GET https://smpt1.aitunnel.shawns-machine.com/v1/models   →  200

qwen3-coder-30b-a3b-instruct
deepseek-coder-v2-lite-instruct
text-embedding-nomic-embed-text-v1.5

I enumerated your loaded models from outside your network with nothing but the URL. /v1/chat/completions is on the same open endpoint — anyone who finds the hostname can run inference on your GPU indefinitely. And because it's the AI tunnel, prompts sent through it may carry project context.

Not urgent-panic — it's an obscure hostname, not indexed. But it's open, and the fix is small.

FixHoldsNote
Bearer-token check at the nginx vhost fronting sishhighYou already terminate TLS there. Reject any request missing the header before it reaches sish.
IP allowlist for your known egresshighTightest, but breaks the moment you want access from somewhere new.
LM Studio's own API key settingneeds checkingRecent builds support it; depends on your version. Would be the cleanest if present.
sish-side HTTP auth flagsneeds checkingsish has auth options but they differ by build — I'd verify against your version rather than guess a flag name at you.

I'd do the nginx token check. It's independent of both sish's version and LM Studio's, it's about ten lines, and it fails closed.

Otherwise — yes, your setup is coherent. Frozen LAN IP plus DDNS plus a pinned port makes the origin stable, and sish is then buying you loopback binding and self-healing reconnects rather than NAT traversal. That's a smaller benefit than the general case, but it isn't zero.

we kille aitunnel.shawns-machine.com since it was to easy to fet to

https://smpt1.aitunnel.shawns-machine.com/v1/models this is one I am using now –

Good — that explains the 502, so my earlier read of it as a failure was wrong.

But the swap didn't change the security posture. Same endpoint, same result:

server: nginx/1.20.1
x-powered-by: Express

POST /v1/chat/completions  {}                        → 400
POST /v1/chat/completions  {}  Bearer junk           → 400

400, not 401. The request passes nginx, passes sish, reaches LM Studio, and gets rejected for malformed JSON — not for missing credentials. A junk token changes nothing because nothing is reading it. The only control on smpt1 is that the name is harder to guess than aitunnel.

And that control isn't really available to you. The cert I pulled carries SAN: DNS:smpt1.aitunnel.shawns-machine.com, and every publicly-trusted cert is published to Certificate Transparency logs by mandate. Those logs are continuously scraped. Mint a hostname with a real CA and the hostname is public within minutes — you can see yours on crt.sh right now. Renaming it again buys you the time between issuance and the next scrape.

The good news is where the fix goes

server: nginx/1.20.1 means nginx terminates in front of sish. So this is an nginx edit, not a sish-version question:

map $http_authorization $ai_ok {
    default                        0;
    "Bearer <long-random-token>"   1;
}

server {
    server_name smpt1.aitunnel.shawns-machine.com;

    location /.well-known/acme-challenge/ { }   # must stay open or renewal breaks

    location / {
        if ($ai_ok = 0) { return 401; }
        proxy_pass http://<sish-upstream>;
    }
}

Fails closed, independent of both sish's and LM Studio's versions, and return inside if is one of the two uses nginx actually sanctions. Keep the ACME location open or on-demand issuance stops working and you'll be debugging a cert problem instead.

One cost worth pricing before you do it

Every client has to start sending that header — and the tunnel URL is hardcoded in every project's embeds/, which is the fan-out problem already on your list. Adding a token means touching all of them, or touching none of them and having Extendy go dark everywhere at once.

That makes this a decent forcing function: fix the fan-out first so the endpoint and its token live in one config value, then turn on the check. Doing it in that order is one edit and one flip. Doing it in the other order is N edits under time pressure with a broken watchdog.

Two things worth separating, because they're not actually linked.

Auth doesn't cost you the wildcard. A bearer check at nginx can sit on the wildcard vhost as easily as on one hostname — same map, one server block matching *.aitunnel.shawns-machine.com. You're not trading the wildcard for security. Those are orthogonal.

Giving users direct access is self-defeating, for a specific reason

The tunnel URL is hardcoded in every project's embeds/ and read by browser JS. Any token you put next to it is visible in devtools on page one. A credential shipped to the client is not a credential — it's a slightly inconvenient public string. So "open it to users, protect it with a token" doesn't compose.

The shape that does work is the one you already built and then bypassed: accid-llm.php. Server-side, holds the token, calls the tunnel, returns the result. Users hit the bridge; the bridge is the only thing that knows the tunnel exists. That's where per-user limits, logging, and revocation can live, because it's the only layer the user can't read.

That also means the fan-out fix and the user-access question are the same piece of work. Route through the bridge and the hardcoded-URL problem dissolves along with the token problem.

Then there's capacity, which is the harder no

From your own log:

eval  21.22 ms/token   →  47 tok/s
eval  14.85 ms/token   →  67 tok/s
slots observed: id 0 through 3

Four slots, one GPU. A 500-token answer is roughly 8–10 seconds of exclusive GPU time. Best case that's on the order of 25 responses a minute across all users, and that's with no thinking, no long context, and nothing else using the card. Add a second concurrent user and they're queueing behind each other.

On a laptop, on home internet, on a link that goes down when the machine sleeps. It's a fine watchdog for you. It is not a service you can put users behind and still call the site reliable.

So why the wildcard?

Because sish's wildcard is for origins, not users. It lets a machine claim its own name at connect time without you editing config. The use it was built for is a second model host, or splitting embeddings off from chat, or a staging model alongside prod — several things dialing in, each claiming a name.

Right now you have one origin, so yes: it's over-built for the current shape. The cost is the second cert scope and the --bind-random-subdomains=false gotcha. The benefit is that when you do put a model on one of the Mac Minis, it's already there — mini1.aitunnel... works with no server-side change.

QuestionAnswer
Keep the wildcard?Yes. Cheap, already working, pays off on origin #2.
Does auth force you to one tunnel?No. Auth is per-vhost and the wildcard is a vhost.
Open the tunnel to users?No. Route them through accid-llm.php instead.
Is a client-side token enough?No — it's public the moment it ships.

The one decision left is whether user-facing AI is a feature you actually want, given that it's rate-limited by a single laptop GPU. If it is, the bridge is the boundary and the capacity ceiling is the thing to design around — a queue and a visible "busy" state, not an open endpoint.

Ha — fair. Ouija board stage is the right place to be pricing this stuff out, not after you've built the queue.

Short answer: not for production ACCID, yes for dev ACCID.

Production sites fail the test on both counts — the origin is stationary and the lifetime is permanent. nginx reaching a directory on its own disk is already the simplest possible thing. Putting sish in that path adds a daemon and a failure mode to solve a problem you don't have.

Dev ACCID hits the test on both, though, and probably more often than the AI box does:

  • Client preview without deploying. ssh -R danzhaus-wip:80:localhost:9845 and Jerry sees the actual thing you're editing, on real HTTPS, for as long as the session lives. No FTP step, no staging site to forget about later.
  • OAuth redirects and webhooks into localhost. Anything that demands a public HTTPS callback — the Tripleseat and Sheets integrations are exactly this. That's the classic ngrok slot and sish fills it without the third party.
  • Testing the portable router at real depth. The IONOS mount-stripping work needs a real hostname with real TLS at several depths. Throwaway subdomain per test run, gone when you close the terminal.
  • Giving Codey or Extendy a URL for a page you haven't deployed. That's your "AI file-reach" problem, solved for the duration of the session.

The rule that falls out: sish for things that are temporary and live where nginx can't reach. nginx for things that are permanent and live where it can. Production is the second. Dev is usually the first.

One caution that transfers directly from the last hour, though. Every tunnel you open mints a cert, and every cert lands in Certificate Transparency within minutes. So a dev tunnel is a public URL, not a private one — and a local ACCID install has accid-bridge.php in it with write actions. Live starter is fine, it 401s on write_perspective. But if a local dev install has no passcode set, tunneling it publishes a writable bridge to the internet under a name that's in a public log.

Worth a one-line check in fire_em_up before any tunnel goes up: passcode set, or don't publish. Cheap insurance for something that would be genuinely bad and completely silent.

Right — I was solving for a topology you don't have. Both Minis are on the LAN behind the same nginx, so the origin is always reachable and every one of those cases is just a vhost.

That collapses the list to one: the Razer. It's the only machine that isn't under the desk, and it's the only one where the origin has to dial out. Which means sish in your setup is a single-purpose tool for the AI box, not general infrastructure.

The thing actually doing the heavy lifting for everything else is the wildcard DNS plus on-demand certs. Adding staging.shawns-machine.com costs a vhost and nothing else — no cert request, no tunnel, no new process. That's why sish has so little surface left to cover.

So: keep it where it is, don't extend it into ACCID. The only thing that would change that is a machine you don't control or can't reach — a client's server, a VPS, a laptop somewhere else. Under the desk isn't it.

Four feet. So the whole tunnel exists because a laptop is on a shelf instead of a desk.

Which does raise the honest version: if the Razer is permanently on that shelf, on the same LAN, with a frozen IP and a pinned port — the roaming argument is gone. What's left is the loopback binding, and that's a real but small win.

The catch is that you already tested proxy_pass to it and killed that path, and the reason you gave was that it was too easy to reach. But that was about the public hostname being guessable, not about how nginx talks to the Razer internally. Those are different layers. You could go back to proxy_pass on the LAN and still put the bearer check on the public vhost — you'd get the same protection you were after, minus a daemon and minus the second cert scope.

The one thing that argues for keeping sish anyway: laptops sleep. A Mini under the desk stays up; a Razer on a shelf with a lid has a power profile, and a static upstream to a sleeping host gives you 502s until it wakes. sish reconnects on its own. Whether that's worth a daemon depends on whether that thing actually sleeps.

If it never does, the simpler stack is nginx straight to 192.168.x.x:1234 with the token check out front. If it does, sish is earning its keep for exactly one reason, and that's fine — one good reason is enough.

Yeah — the setup holds up. Minis on the LAN get vhosts, the Razer dials out, and the wildcard means adding either kind costs almost nothing. That's the right split for the machines you actually have.

The one thing that came out of it that isn't settled: smpt1 takes unauthenticated requests from anywhere, and the hostname is in CT logs, so the obscurity isn't doing the work. Whenever you're back on the tunnel — fan-out first, then the nginx token check.

The loader refresh is still queued too, whenever you want it.

oh eyah - so 

on the .26 (reverse proxy) ? I add this to the conf?

map $http_authorization $ai_ok {
    default                        0;
    "Bearer <long-random-token>"   1;
}
server {
    server_name smpt1.aitunnel.shawns-machine.com;
    location /.well-known/acme-challenge/ { }   # must stay open or renewal breaks
    location / {
        if ($ai_ok = 0) { return 401; }
        proxy_pass http://<sish-upstream>;
    }
}

Close, but don't paste that as a new block — that's the version that fails after the long run.

Three problems with it as written:

1. A second server block with the same server_name is a silent no-op. nginx logs conflicting server name as a warning, keeps the first match, and ignores yours. You'd reload cleanly, see no error, and still be unauthenticated. There is already a block serving smpt1 — that's the one to edit.

2. No listen 443 ssl or cert directives. As written it defines a plain :80 server. Your traffic is HTTP/2 over TLS, so it would never match.

3. Overwriting location / will take your existing proxy directives with it. LM Studio streams SSE. If the current block has proxy_buffering off and a raised proxy_read_timeout and you replace it, streaming completions break — and they break partway through a long generation, which is the worst way to find out.

What to actually do

Find the real block first:

nginx -T 2>/dev/null | grep -n "smpt1\|aitunnel" 
grep -rn "smpt1\|aitunnel" /etc/nginx/

Then two edits, in that file:

# TOP OF FILE — outside every server block.
# `map` is only valid in the http context. Files in conf.d are already
# included inside http{}, so the top of the file is correct.
# Inside a server block it fails nginx -t.
map $http_authorization $ai_ok {
    default                       0;
    "Bearer PASTE_LONG_RANDOM"    1;
}

Then inside the existing server block, add one line at the top of the existing location /, leaving everything else in it untouched:

location / {
    if ($ai_ok = 0) { return 401; }     # <- only new line

    proxy_pass          http://...;     # keep whatever is already here
    proxy_set_header    ...;            # keep
    proxy_buffering     off;            # keep — streaming depends on it
}

Generate the token with openssl rand -hex 32. The match is an exact string compare — Bearer with one space, case-sensitive. A client sending lowercase bearer gets a 401.

Verify before you trust it

nginx -t && systemctl reload nginx

curl -s -o /dev/null -w "%{http_code}\n" https://smpt1.aitunnel.shawns-machine.com/v1/models
# expect 401

curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer YOURTOKEN" \
     https://smpt1.aitunnel.shawns-machine.com/v1/models
# expect 200

Then run one real streaming completion with the token before you call it done — that's the check that catches a clobbered proxy_buffering.

One unknown I can't resolve from outside

The ACME location only matters if renewal uses HTTP-01 through this vhost. Your certs are 30-day and were minted this morning, which suggests something automated — but afraid.org has no DNS-01 plugin, which is why you've been renewing by hand. Those two facts don't fit together, and I can't see which is true from here.

So: grep -rn "acme\|certbot\|challenge" /etc/nginx/ and check for a certbot timer. If challenges come through nginx, keep an explicit ACME location above location / so it isn't caught by the 401. If renewal is DNS-01 or handled by another process, the ACME location is noise — leave it out rather than adding an empty block with no root, which has its own failure mode.

It's a real reason, and it lines up with SPI-549 — which you filed to kill manual renewal. Three things you'd get:

DNS-01 renewal via API. certbot has a first-class Cloudflare plugin. That's the whole reason afraid.org has been painful — no DNS-01 support, so wildcards renew by hand on an 83-day cadence. This removes the chore rather than rescheduling it.

Auth at the edge instead of in nginx. Cloudflare Access with a service token does what the map/if block does, without touching your conf. Better fit too: a service token lives server-side in accid-llm.php, which is where you want the credential anyway.

Origin IP hidden. Right now afraid.org points a public A record at your house.

The one that decides cost

Universal SSL covers one wildcard level; to cover deeper levels you must explicitly add a wildcard for each level to an advanced certificate, and ACM is paid. So:

smpt1.aitunnel.shawns-machine.com     two labels  →  ACM, ~$10/mo
smpt1-ai.shawns-machine.com           one label   →  Universal SSL, free

That's now the third independent reason to flatten the namespace — same one-label limit you're already hitting with your own certs. Do the rename and Cloudflare is free for this. Keep the nesting and it's a subscription.

Worth knowing the two wildcards behave differently, since it's a common trap: a DNS record at *.example.com is multi-level by default and covers 123.abc.example.com — so DNS resolves fine at any depth. It's the certificate that stops at one label. Everything works right up until TLS.

The timeout is a non-issue for you

The 100-second ceiling isn't adjustable on Free, Pro, or Business, which sounds alarming for LLM traffic. But the countdown is until response headers arrive, not until the body finishes — the origin must begin sending a response within the window. LM Studio sends headers before the first token, so streaming is fine. And even buffered, your max_tokens: 2000 at the 47–67 tok/s in your log is roughly 30–45 seconds. Comfortable margin either way.

Order of operations

  1. Flatten smpt1.aitunnel.*smpt1-ai.*. Decides free vs paid, and retires the second cert scope.
  2. Move DNS to Cloudflare, point DDNS at their API.
  3. certbot DNS-01 → cancel the manual renewal tickets.
  4. Fan-out fix so the endpoint lives in one config value.
  5. Then Access service token, or the nginx block — by then you can pick whichever is less work.

Steps 1 and 4 are worth doing regardless of whether you move to Cloudflare at all, which makes them the safe things to start with.

Correction — my cert readings were worthless, and I should have caught it sooner

issuer = O = Anthropic, CN = Egress Gateway SDS Issuing CA (production)

probe A  serial 47747D0E…  notBefore Aug 18 16:49:52
probe B  serial E49BA4EA…  notBefore Aug 18 16:50:35     (20 seconds later)

Every certificate I've inspected this session was minted by the TLS-intercepting proxy my sandbox egresses through. Not yours. New serial on every connection, which is exactly why the timestamps kept landing minutes before each probe.

So retract all of this:

  • "Certs appear to be auto-issued, not manual" — no. Your manual 83-day cadence stands. Don't cancel SPI-543 through SPI-548.
  • "Something minted a cert on a dormant hostname today" — that was my proxy. There is no rogue automation to hunt.
  • "The SAN proves the hostname is in CT logs" — the reasoning holds for your real cert, but my evidence for it was fabricated by the proxy.

The document you pasted from the other session has the same error baked in, and it was pointing you at "find what minted those certs" as the thread to pull first. That thread goes nowhere. Drop it.

I also can't verify your TLS setup at all from here — not the issuer, not the expiry, not whether the chain is valid. Anything cert-related has to come from openssl run on your own network.

What still holds

Status codes and response bodies pass through the proxy intact:

502   aitunnel.shawns-machine.com/v1/models
200   smpt1.aitunnel.shawns-machine.com/v1/models   ← full model list, no auth

The endpoint being open is real and unchanged. That was never a cert observation.

The conf you found is the 502, not the live one

server_name aitunnel.shawns-machine.com; is an exact name — it cannot serve smpt1.aitunnel.shawns-machine.com. So this file is the dead direct-proxy path, and adding auth to it would protect nothing.

Its proxy_pass http://192.168.1.21:1234 is what's 502-ing. Given the file's own note — "IF .21's IP EVER CHANGES: update proxy_pass" — that's the first thing to test, from .26:

curl -m 5 http://192.168.1.21:1234/v1/models

Reachable means LM Studio moved or nginx can't route. Unreachable means the IP changed, LM Studio is bound to loopback, or the Razer's firewall profile flipped.

Then find what actually serves smpt1:

nginx -T 2>/dev/null | grep -n "aitunnel\|server_name"
ls /etc/nginx/conf.d/

One nginx behaviour worth knowing while you read that output: nginx's server_name wildcards match multiple labels, unlike DNS records and unlike TLS certs. *.shawns-machine.com in nginx will match smpt1.aitunnel.shawns-machine.com. So your wildcard catch-all is a live candidate for what's serving it — in which case auth goes there, or on a new longer-wildcard vhost that outranks it.

Certificates are the opposite: *.shawns-machine.com does not cover two labels. So whatever serves smpt1 needs its own cert, and that's a real question about your setup I can't answer from outside.

Your instinct on the custom header is right, and better than what I suggested earlier — X-Mesh-Key avoids the Authorization: Bearer collision entirely, and all the callers are your own code.

Tabbbbbbbbbcontent