agentleFS
xingfanxiaxingfanxia/AX-skills/proxy-node-setup/SKILL.md

proxy-node-setup

Skill4 starsChanged 54 days agoMITView on GitHub

Set up a self-hosted sing-box proxy/VPN service end-to-end (VLESS-Reality / Hysteria2 / TUIC / VMess-WS / AnyTLS) on a fresh VPS — multi-protocol server, TLS cert via Cloudflare DNS-01, nginx subscription server, traffic stats. The node can be brought up two ways: a full fresh install, or by cloning an existing node. Use when the user wants to "set up a VPN on this VPS", "set up sing-box", "搭个梯子 / 搭一套翻墙服务", "add a proxy node", or "clone my proxy to another host".

  • Pipes a download into a shell
  • Installs packages
  • Sends data out

The file

We split this skill into the rules it gives an agent

4 rules. About 0 are common, 0 are distinctive, and 4 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 4

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    If it can't read the zone → stop and ask for a fresh token.

    line 51
  2. Very specific:

    Verify: curl -sI https://$NEW_DOMAIN/TOKEN → server: cloudflare, cf-cache-status: DYNAMIC, HTTP 200.

    line 181
  3. Very specific:

    Don't claim "working" off systemctl is-active alone.

    line 219
  4. Very specific:

    If the Bash tool returns empty output, stop — do not narrate success you didn't see.

    line 256

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

proxy-node-setup

Set up a self-hosted sing-box proxy service on a fresh VPS, end-to-end: multi-protocol server + TLS cert + nginx subscription + traffic stats. Companion to the public blog runbook at blog.ax0x.ai/proxy-runbook-zh. Showcase deck: https://xingfanxia.github.io/AX-skills/proxy-node-setup/.

The setup is one pipeline. Only how the node first comes up differs — then DNS → cert → subscription → verify are identical either way:

Starting pointHow the node comes up
First node / greenfield (no reference)Fresh install — yonggekkk sb.sh one-click installer (the canonical path)
You already run a working node and want anotherClone — copy the reference node's /etc/s-box, rewrite IP + instance id (fast, deterministic, skips the interactive installer)

The config layout (yonggekkk/sing-box-yg): everything lives in /etc/s-box/ (server config sb.json, client subs clmi.yaml + sbox.json, share-link .txts, sing-box binary, cert.pem/private.key, update_traffic.sh). A sb shortcut (/usr/bin/sb) opens the management menu. nginx serves the subscription over TLS. acme.sh issues/renews the cert.


Step 0 — Collect inputs (never hardcode; substitute these)

NEW_IP=                # new host public IP
NEW_SSH_PORT=          # new host SSH port (provider email)
NEW_ROOT_PW=           # new host root password (or use a key)
NEW_DOMAIN=            # e.g. tw.example.com  — subdomain for THIS node
NEW_INSTANCE=          # provider instance id, used in node display names (e.g. C20260601088898)
CF_TOKEN=              # Cloudflare API token: Zone:DNS:Edit + Zone:Read (+ Zone:Origin Rules:Edit if you'll use the clean orange-cloud sub path in Step 5)
ZONE=                  # apex zone, e.g. example.com
# Clone-mode only:
REF_IP=  ; REF_SSH_PORT= ; REF_ROOT_PW=   # the reference node to clone from
REF_INSTANCE=                              # reference node's instance id (to rewrite)

SSH helper (these scripts run from a Mac/agent; sshpass for password auth):

SSHO="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=30"
NEW(){ sshpass -p "$NEW_ROOT_PW" ssh $SSHO -p "$NEW_SSH_PORT" root@"$NEW_IP" "$@"; }
REF(){ sshpass -p "$REF_ROOT_PW" ssh $SSHO -p "$REF_SSH_PORT" root@"$REF_IP" "$@"; }

Verify the CF token BEFORE relying on it (a stale/expired token is the classic silent failure — it also means acme auto-renew has been quietly failing). Use a functional zone read, NOT /user/tokens/verify — that user-scoped endpoint returns Invalid API Token for a perfectly good zone-scoped token (false negative). A real zone read is the truth:

curl -s "https://api.cloudflare.com/client/v4/zones?name=$ZONE" -H "Authorization: Bearer $CF_TOKEN" \
  | python3 -c "import sys,json;d=json.load(sys.stdin);print('token ok, zone_id='+d['result'][0]['id'] if d.get('success') and d.get('result') else 'TOKEN DEAD/NO-ZONE-ACCESS: '+str(d.get('errors')))"

If it can't read the zone → stop and ask for a fresh token. Prefer a non-expiring token so renewals don't break later. (A truly dead token fails both this read and /user/tokens/verify; a zone-scoped-but-valid token fails only the latter.)


Step 1 — DNS record (Cloudflare API, gray cloud)

Proxy protocols connect by IP, not domain; the domain is only for the subscription + TLS cert. Use DNS-only (gray cloud) — Reality/Hysteria/TUIC can't traverse Cloudflare's proxy. (Orange cloud is only worth it if you need CN users to fetch the subscription via CDN — and only the sub, never the proxy ports.)

ZID=$(curl -s "https://api.cloudflare.com/client/v4/zones?name=$ZONE" -H "Authorization: Bearer $CF_TOKEN" \
      | python3 -c "import sys,json;r=json.load(sys.stdin)['result'];print(r[0]['id'] if r else '')")
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$ZID/dns_records" \
  -H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json" \
  --data "{\"type\":\"A\",\"name\":\"$NEW_DOMAIN\",\"content\":\"$NEW_IP\",\"ttl\":120,\"proxied\":false}" \
  | python3 -c "import sys,json;d=json.load(sys.stdin);print('created' if d['success'] else d['errors'])"
dig +short "$NEW_DOMAIN" @1.1.1.1   # confirm resolves to NEW_IP

Step 2 — Bootstrap the new host

NEW 'export DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a
apt-get update -qq && apt-get install -y -qq curl wget vim git unzip net-tools vnstat nginx python3 socat cron
timedatectl set-timezone UTC
grep -q tcp_congestion_control=bbr /etc/sysctl.conf || printf "net.core.default_qdisc=fq\nnet.ipv4.tcp_congestion_control=bbr\n" >> /etc/sysctl.conf
sysctl -p >/dev/null; echo "BBR=$(sysctl -n net.ipv4.tcp_congestion_control)"'

Do NOT enable ufw blindly — the reference nodes run with no ufw (open iptables ACCEPT, provider edge handles filtering). Enabling ufw with the wrong ports can lock you out of SSH. Match the reference node.

Detect the real NIC — never hardcode ens3/eth0. Recent Ubuntu/KVM images use ens17 (both reference nodes were ens17):

IFACE=$(NEW 'ip -o -4 route show to default | awk "{print \$5}"'); echo "iface=$IFACE"

Step 3 (clone path) — clone an existing node

Fast path when you already run a working node. For a first/greenfield node use the fresh install below instead — then both converge on Step 4.

Tar /etc/s-box from the reference, move it via the local machine, extract on the new host. The binary + sb.json are host-independent (listen ::, Reality steals a public SNI, certs by path) so they copy verbatim. Only IP / instance-id / cert / domain are host-specific.

# pull reference config
REF 'cd /etc && tar czf /tmp/sbox.tgz s-box'
sshpass -p "$REF_ROOT_PW" scp -q $SSHO -P "$REF_SSH_PORT" root@"$REF_IP":/tmp/sbox.tgz /tmp/sbox.tgz; REF 'rm -f /tmp/sbox.tgz'
REF 'cat /etc/systemd/system/sing-box.service' > /tmp/sing-box.service
REF 'cat /usr/bin/sb' > /tmp/sb
# push to new host
sshpass -p "$NEW_ROOT_PW" scp -q $SSHO -P "$NEW_SSH_PORT" /tmp/sbox.tgz root@"$NEW_IP":/tmp/sbox.tgz
sshpass -p "$NEW_ROOT_PW" scp -q $SSHO -P "$NEW_SSH_PORT" /tmp/sing-box.service root@"$NEW_IP":/etc/systemd/system/sing-box.service
sshpass -p "$NEW_ROOT_PW" scp -q $SSHO -P "$NEW_SSH_PORT" /tmp/sb root@"$NEW_IP":/usr/bin/sb
NEW 'cd /etc && tar xzf /tmp/sbox.tgz && rm -f /tmp/sbox.tgz && chmod +x /usr/bin/sb /etc/s-box/sing-box && /etc/s-box/sing-box version | head -1'

Rewrite host-specific identifiers (IP everywhere; instance-id in display names; reset traffic counter). vmess share-link is base64 — decode/re-encode:

NEW "bash -s" <<EOF
set -e; cd /etc/s-box
for f in clmi.yaml clmi.yaml.bak sbox.json sb10.json sb11.json vl_reality.txt hy2.txt tuic5.txt an.txt jhdy.txt jhsub.txt server_ip.log server_ipcl.log; do
  [ -f "\$f" ] && sed -i "s/${REF_IP}/${NEW_IP}/g; s/${REF_INSTANCE}/${NEW_INSTANCE}/g" "\$f"
done
[ -f vm_ws.txt ] && python3 - <<'PY'
import base64
r=open('vm_ws.txt').read().strip()[len('vmess://'):]
j=base64.b64decode(r+'='*(-len(r)%4)).decode().replace("${REF_IP}","${NEW_IP}").replace("${REF_INSTANCE}","${NEW_INSTANCE}")
open('vm_ws.txt','w').write('vmess://'+base64.b64encode(j.encode()).decode()+'\n')
PY
cat > /etc/s-box/.traffic_header.conf <<H
add_header Subscription-Userinfo "upload=0; download=0; total=3221225472000; expire=0";
H
echo "stray refs: \$(grep -rl '${REF_IP}\|${REF_INSTANCE}' /etc/s-box || echo none)"
/etc/s-box/sing-box check -c /etc/s-box/sb.json && echo CONFIG_OK
EOF

Reused keys note. Cloning reuses the reference node's UUID + Reality keypair + protocol passwords. Functionally fine for one owner's nodes (client links differ only by IP/name). To give the node its own identity, regenerate via the sb menu afterward.

The subscription token (/etc/s-box/.sub_token) is copied too — the new node's sub path matches the reference. Generate a fresh one if you prefer per-node tokens.

Step 3 (fresh path) — fresh install (greenfield, canonical first-node path)

NEW 'bash <(wget -qO- https://raw.githubusercontent.com/yonggekkk/sing-box-yg/main/sb.sh)'   # interactive: choose 1 (install), accept defaults

This is interactive — drive it in a real terminal/PTY, then come back and run Steps 4-6. Note actual ports from the output (the script randomizes them; don't copy port numbers from any doc). Reality SNI defaults to a public site (e.g. apple.com/yahoo.com); the steal-cert handshake proves the inbound is live.


Step 4 — TLS cert (acme.sh, Cloudflare DNS-01)

DNS-01 needs no inbound port — important because some providers firewall 80/443 (see Step 5). Cert is used by nginx (validated by clients) and by Hysteria2/TUIC (clients skip-verify, so any valid cert works).

NEW 'curl -s https://get.acme.sh | sh -s email=YOUR_EMAIL'   # idempotent
NEW "export CF_Token='$CF_TOKEN'
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt
mkdir -p /root/ygkkkca
~/.acme.sh/acme.sh --issue --dns dns_cf -d $NEW_DOMAIN --ecc --server letsencrypt
~/.acme.sh/acme.sh --install-cert -d $NEW_DOMAIN --ecc \
  --cert-file /root/ygkkkca/cert.crt --key-file /root/ygkkkca/private.key --fullchain-file /root/ygkkkca/fullchain.crt \
  --reloadcmd 'cp /root/ygkkkca/fullchain.crt /etc/s-box/cert.pem && cp /root/ygkkkca/private.key /etc/s-box/private.key && systemctl restart sing-box && nginx -s reload'"
NEW 'openssl x509 -in /root/ygkkkca/fullchain.crt -noout -subject -dates'

Cert path sync (gotcha): nginx reads /root/ygkkkca/, sing-box reads /etc/s-box/cert.pem. The --reloadcmd above keeps both in sync on every renewal — without it, renewals succeed but sing-box keeps serving the old cert. First-run note: --install-cert runs the reloadcmd immediately. If the nginx sub site isn't created yet (Step 5 not done), the trailing nginx -s reload may print a warning — harmless; Step 5's systemctl restart nginx settles it. The sing-box half (copy + restart) is what matters here.


Step 5 — nginx subscription server

⚠️ Provider firewall check — the #1 surprise. Some hosts (e.g. Taiwan-origin VPS) silently drop inbound 80/443 while leaving high ports open. nginx will listen fine locally yet be unreachable. Test from outside before declaring done. Confirm it's an edge block (not the host): nginx answers curl localhost:443 (404 = responding), the box has no firewall, yet two different external networks can't reach 443. Two fixes:

  1. Quick — also listen <HIGHPORT> ssl; (e.g. 2096) and hand out the sub with the port: https://domain:2096/TOKEN.
  2. Clean (preferred, also relays for CN) — keep the high-port listener as the origin, then put the sub subdomain behind Cloudflare orange cloud + an Origin Rule that rewrites the origin port. Client hits clean https://$NEW_DOMAIN/TOKEN on CF's 443; CF connects to origin on the open port. Needs token perm Zone > Origin Rules > Edit; zone SSL must be Full-class (it already is if other proxied records work):
    B=https://api.cloudflare.com/client/v4
    # 1) flip the sub record to orange cloud (re-fetch the record id — Step 1 didn't keep it)
    RID=$(curl -s "$B/zones/$ZID/dns_records?type=A&name=$NEW_DOMAIN" -H "Authorization: Bearer $CF_TOKEN" \
          | python3 -c "import sys,json;print(json.load(sys.stdin)['result'][0]['id'])")
    curl -s -X PATCH "$B/zones/$ZID/dns_records/$RID" -H "Authorization: Bearer $CF_TOKEN" \
      -H "Content-Type: application/json" --data '{"proxied":true}'
    # 2) Origin Rule -> origin port 2096. APPEND, don't clobber: a bare PUT to the entrypoint
    #    REPLACES every origin rule on the zone, so read existing rules, add ours, write back.
    EP=$(curl -s "$B/zones/$ZID/rulesets/phases/http_request_origin/entrypoint" -H "Authorization: Bearer $CF_TOKEN")
    BODY=$(echo "$EP" | NEW_DOMAIN="$NEW_DOMAIN" python3 -c "import sys,json,os; d=os.environ['NEW_DOMAIN']; r=[x for x in ((json.load(sys.stdin).get('result') or {}).get('rules') or []) if d not in x.get('expression','')]; r.append({'action':'route','action_parameters':{'origin':{'port':2096}},'expression':'(http.host eq \"%s\")'%d,'enabled':True}); print(json.dumps({'rules':r}))")
    curl -s -X PUT "$B/zones/$ZID/rulesets/phases/http_request_origin/entrypoint" \
      -H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json" --data "$BODY"
    Verify: curl -sI https://$NEW_DOMAIN/TOKEN → server: cloudflare, cf-cache-status: DYNAMIC, HTTP 200. (Proxy protocols connect by raw IP and are untouched by orange cloud.)
TOKEN=$(NEW 'cat /etc/s-box/.sub_token')
NEW "cat > /etc/nginx/sites-available/sub <<NGINX
server {
    listen 80;
    listen 443 ssl;
    listen 2096 ssl;          # fallback high port if provider blocks 443
    server_name $NEW_DOMAIN;
    ssl_certificate /root/ygkkkca/fullchain.crt;
    ssl_certificate_key /root/ygkkkca/private.key;
    ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5;
    location /$TOKEN { alias /etc/s-box/clmi.yaml; default_type 'text/plain; charset=utf-8'; add_header Content-Disposition inline; include /etc/s-box/.traffic_header.conf; }
    location /singbox/$TOKEN { alias /etc/s-box/sbox.json; default_type 'application/json; charset=utf-8'; include /etc/s-box/.traffic_header.conf; }
    location / { return 404; }
}
NGINX
rm -f /etc/nginx/sites-enabled/default
ln -sf /etc/nginx/sites-available/sub /etc/nginx/sites-enabled/sub
nginx -t && systemctl restart nginx"   # restart (not reload) to drop removed listen sockets

Traffic stats (vnstat → Subscription-Userinfo header so clients show usage). Reference clone already ships update_traffic.sh; just fix the interface + cron:

NEW "sed -i 's/^IFACE=.*/IFACE=\"$IFACE\"/' /etc/s-box/update_traffic.sh
systemctl enable --now vnstat
( crontab -l 2>/dev/null | grep -vE 'update_traffic|acme.sh --cron|restart sing-box';
  echo '0 1 * * * systemctl restart sing-box';
  echo '42 3 * * * \"/root/.acme.sh\"/acme.sh --cron --home \"/root/.acme.sh\" > /dev/null';
  echo '*/5 * * * * /bin/bash /etc/s-box/update_traffic.sh > /dev/null 2>&1' ) | crontab -
systemctl daemon-reload && systemctl enable --now sing-box && bash /etc/s-box/update_traffic.sh"

Step 6 — Validate end-to-end (from OUTSIDE, with real evidence)

Don't claim "working" off systemctl is-active alone. Prove reachability + actual egress.

# A. external port reachability (run from your machine, NOT the server). nc -G is macOS/BSD; on Linux use -w.
#    TCP: 443/2096 + TCP proxy ports. UDP (Hysteria2/TUIC) need -u and are best proven via part C egress, not nc.
for p in <TCP_PROXY_PORTS> 443 2096; do nc -z -G6 $NEW_IP $p && echo "tcp/$p OPEN" || echo "tcp/$p FILTERED"; done
for p in <UDP_PROXY_PORTS>; do nc -zu -G6 $NEW_IP $p && echo "udp/$p likely-open" || echo "udp/$p inconclusive"; done
# B. cert validates + subscription returns config (use the open port from A)
echo | openssl s_client -connect $NEW_DOMAIN:PORT -servername $NEW_DOMAIN 2>/dev/null | openssl x509 -noout -subject -dates
curl -s "https://$NEW_DOMAIN:PORT/$TOKEN" -D - -o /tmp/sub.txt | grep -iE '^HTTP|subscription-userinfo'; grep -c 'server:' /tmp/sub.txt
# C. GOLD STANDARD — real traffic flows. Run a local sing-box client through ONE protocol, confirm egress IP == NEW_IP.
#    Build a minimal client (mixed inbound :10808 + one VLESS-Reality outbound from the share link), then:
#    curl --socks5-hostname 127.0.0.1:10808 https://api.ipify.org   → must print NEW_IP
#    Keep client-start + curl in ONE shell invocation (a backgrounded client dies when its task ends).

A VLESS-Reality TLS probe (openssl s_client -connect $NEW_IP:<reality_port> -servername <sni>) returning the real SNI site's cert (e.g. apple.com EV) confirms the Reality inbound is live.

Deliver: https://$NEW_DOMAIN[:PORT]/$TOKEN (clash/mihomo) and …/singbox/$TOKEN (sing-box).


Gotchas (each one cost real time)

#SymptomCauseFix
1Sub URL times out, proxy worksprovider firewalls inbound 80/443serve sub on an open high port (:2096); test from outside
2acme/DNS silently brokenCF token expired/revokedverify token first; use long-lived token; renew refreshes only with a valid token
3traffic stats empty / wrongIFACE hardcoded ens3detect via ip route; modern images = ens17
4sing-box serves old cert after renewnginx & sing-box read different cert paths--reloadcmd copies cert to /etc/s-box/ + restarts
5locked out of SSHenabled ufw with wrong portsmatch reference (no ufw); never blind-enable
6nginx still binds removed ports after reloadgraceful worker shutdown keeps old listenerssystemctl restart nginx
7Hysteria2/TUIC "refused" on testthey're UDP, nc -z/telnet test TCPnc -zu or ss -ulnp
8vmess:// link wrong after cloneit's base64(JSON)decode → substitute → re-encode

Agent execution notes

  • Sequence stateful/fallible SSH+curl commands; don't fan them out in one parallel batch. One non-zero exit in a parallel tool-call batch cancels every sibling call. (See memory feedback_parallel_bash_cascade.)
  • If the Bash tool returns empty output, stop — do not narrate success you didn't see. Write output to a file and Read it back to confirm.
  • DNS-record creation, cert issuance, and DNS edits are external mutating ops — they're in-scope for this task, but pause for the user on anything the reference node didn't have (e.g. destroying records, rotating shared keys).

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.