Files
teamai-global/skills/expose-service-subdomain/SKILL.md
T
NightStar 3d3dd79df0 Add 39 shared skills from local agent inventory
Sources: ~/.openclaw/skills, ~/.agents/skills, workshop-skills, workspace/skills
2026-09-10 16:54:57 +08:00

7.5 KiB

name, description
name description
expose-service-subdomain Expose an internal K3s/container service to a public *.yoresee.cc URL via the edge nginx reverse proxy (DNS→certbot→site rewrite→base_url→verify), and harden a static download site (autoindex off / robots.txt).

Expose a service on the edge nginx via subdomain

Push an internal K3s/container service to a public *.yoresee.cc URL through the edge nginx on 十堰电信4c8g (125.208.22.116). Targets: a K3s ClusterIP (use svc IP:port) or a container on 襄阳2c4g/other node (use node IP:port).

If the service is not running yet (no image/container/pod), obtain and run it first — see deploy-docker-service.

Steps

  1. Confirm DNS first. getent hosts <domain> must return the edge IP 125.208.22.116. If it does not, stop and ask the user to add the A record — do not proceed until it resolves.

  2. Sign the cert WITHOUT rewriting config. Reuse the existing Let's Encrypt account:

    certbot certonly --nginx -d <domain> --non-interactive --agree-tos --keep-until-expiring
    

    ⚠️ Only certonly is safe. Do NOT run plain certbot --nginx — its auto-configuer rewrites the reverse-proxy site (breaks the 80→443 redirect and the proxy_pass). Preserve the site file by hand.

  3. Back up the current site config, then rewrite it in the edge pattern (80 redirect + 443 http2 proxy):

    server {
        listen 80; listen [::]:80; server_name <domain>;
        return 301 https://$host$request_uri;
    }
    server {
        listen 443 ssl http2; listen [::]:443 ssl http2; server_name <domain>;
        ssl_certificate /etc/letsencrypt/live/<domain>/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/<domain>/privkey.pem;
        location / {
            proxy_pass http://<TARGET_IP>:<TARGET_PORT>;
            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;
        }
    }
    

    TARGET_IP: for a K3s service use its ClusterIP (kubectl get svc -A), for a container on another node use that node reachable IP. For a web UI add client_max_body_size / long timeouts as the service needs.

  4. Match any base_url to the final URL. If the service supports a server.base_url (e.g. searxng) and you serve it at the root domain, set base_url: "https://<domain>/" (no subpath). If an earlier step had mounted it under a subpath (/searx/), revert that — remove the trailing subpath from base_url AND delete the location /subpath/ block in nginx. Mismatched base_url leaves pages loading but static assets pointing at the wrong path.

  5. Reload and verify each domain. nginx -t then systemctl reload nginx. Then:

    curl -s -m 12 -o /tmp/x.html -w "HTTP %{http_code}\n" https://<domain>/
    grep -o '<title>[^<]*' /tmp/x.html | head -1
    grep -o 'src="[^"]*"' /tmp/x.html | head -2
    

    Confirm the src paths have no leftover /subpath/ prefix (should be root /static/... for a root-mounted service). Run a real query through the public URL if it is search-like. If the service requires auth, pull the real credential from the K8s Secret (kubectl get secret <name> -o jsonpath='{.data.<KEY>}' | base64 -d) and use it — never hand-type a placeholder, which yields a spurious 401/bad credentials and a wasted cycle.

  6. Record the mapping in the workspace memory so future work does not collide with the domain.

Hardening a static download site (optional)

downloads.yoresee.cc serves /var/www/downloads from the edge host with autoindex on, so its root listed every file and AI crawlers (GPTBot on Azure, ClaudeBot on AWS — check /var/log/nginx/access.log for the UA) walked the listing. To stop enumeration:

  1. Back up the site file to /root/backup/, then turn the listing off (autoindex on → autoindex off), nginx -t, systemctl reload nginx. This is the real lock: the root then returns 403.
  2. Write robots.txt into the docroot: one User-agent: <bot> + Disallow: / block per crawler, then a permissive User-agent: *; chown it to the docroot owner. Advisory only — it does not stop an ignoring crawler.
  3. Verify over loopback with the Host header (no DNS round trip): curl -s -k https://127.0.0.1/ -H "Host: <domain>" → 403; curl -s -k -r 0-1000 https://127.0.0.1/<file> -H "Host: <domain>" → 206 for a real file, so existing direct links (e.g. shared into Feishu) keep working.

Pitfalls

  • certbot --nginx (auto) destroys the reverse-proxy site — always use certonly --nginx and write the site file yourself.

  • base_url must equal the public origin (path included) or assets 404 / pages load broken.

  • The edge nginx site files live in /etc/nginx/sites-enabled/; back up (to /root/backup/) before rewriting so the switch is reversible.

  • Some images ship with no default command (e.g. binwiederhier/ntfy:v2): the entrypoint prints help and exits, so the pod CrashLoopBackOffs right after a successful pull. Fix in the Deployment by adding the server subcommand explicitly, e.g. args: ["serve"] (ntfy) or the equivalent serve/server subcommand for that image. Verify with kubectl logs -l app=<name>: help output = missing command; Listening on = fixed.

  • A multi-container app's own front-end nginx (location /api/ { set $api_upstream http://<svc>:<port>; proxy_pass $api_upstream; }) fails with 502 when <svc> is a short name: nginx's resolver does NOT apply the Kubernetes search domain (unlike nslookup/libc), so coredns returns NXDOMAIN for single-label names. Use the fully-qualified service DNS in the app's nginx.conf and rebuild/redeploy that web image. The FQDN is <svc>.<namespace>.svc.cluster.local:<port> — the namespace segment is a literal part of the name, so a service in default is api.default.svc.cluster.local:8080 but the same service deployed in a non-default namespace (e.g. test) is api.test.svc.cluster.local:8080, NOT default. Copying the default example when the service lives elsewhere will 502. Short names only work for the edge nginx's proxy_pass because that resolves at startup via libc, not the resolver.

  • certbot certonly --nginx fails if the site config is written with ssl_certificate /etc/letsencrypt/live/<domain>/fullchain.pem BEFORE the cert exists — certbot runs nginx -t, which errors "cannot load certificate". Two ways out: (a) create the site file WITHOUT the ssl_certificate lines, sign the cert, then add them; or (b) the simpler route — sign first with webroot certbot certonly --webroot -w /var/www/html -d <domain> --non-interactive --agree-tos --keep-until-expiring, which needs no live config, then write the full site. If --nginx errors with a missing-cert message, switch to webroot.

  • Do not expose a public download/serve on bare port 80 plaintext HTTP — ISP DPI truncates it. Serving a static file from http://125.208.22.116:80 (no domain/HTTPS) works locally but the public path returns truncated data followed by a synthetic 404. Key diagnostic: the nginx access log records 200 with a large body_bytes_sent (e.g. 231393) while curl only downloaded a few KB or got a 404, and a second egress (aliyun) sees the same 404. That mismatch (log says 200, client got 404/partial) is DPI, not a config bug. Fix: serve via HTTPS on 443 for a real domain — the same file over HTTPS downloads fully. For any public serve/download job, go straight to domain + certbot (webroot) + 443 and verify over HTTPS; do not burn cycles testing bare-IP:80.