Reference

Custom obfs-https front site

Serve your own website (or mirror a public one) on the reality / obfs-https port — the config, and what a browser sees.

The REALITY / obfs-https transport lets a node accept mesh traffic on a port that is already serving a website — normally 443. MeshHold owns the port and behaves as a gateway: peers whose TLS handshake carries an auth marker get the encrypted mesh, and every other visitor (a browser, a port scanner, an ISP probe) is transparently forwarded to whatever dest points at.

Two things come out of that. You don't open another port on the public interface, and the mesh listener is invisible to anything that lacks the key — a scanner sees an ordinary web server and nothing else.

So dest is the front site the outside world sees on that port. You can point it at any real HTTPS server: an existing public site, or your own. This page covers serving your own site; enabling the transport in the first place is in The Private Mesh VPN. The same arrangement on 22, in front of sshd, is here.

How the port is shared

MeshHold owns the public port (usually 443) and decides per connection, from the first packet of the TLS handshake:

Client What happens
MeshHold peer Auth marker present → the node terminates TLS itself and the mesh rides on top. Your front server never sees it.
Anyone else No marker → the raw TCP connection is spliced to dest, ClientHello and all.

The splice is byte-for-byte: MeshHold does not terminate TLS for visitors and never sees the plaintext. The certificate and private key live on the front server, the browser's padlock is end-to-end with it, and SNI passes through untouched so name-based virtual hosts work normally.

By default the relay carries no client address, so the front server logs the node's loopback dial for every visitor. If your site needs the real client IP — geo rules, rate limits, fail2ban — enable proxy_protocol and configure the front server to expect it.

1. Run an HTTPS server for the site

Run it on a local port the node can reach — e.g. 127.0.0.1:4443 — because MeshHold is holding 443. It must terminate TLS 1.3 with a CA-trusted certificate for your domain.

Already serving this site on 443?

If the domain already runs on this host on the public 443, you're not starting fresh — you're moving that server off 443 onto a loopback port so MeshHold can take 443 in front of it. Repoint the existing vhost's listener at 127.0.0.1:4443, keeping its certificate and server_name / ServerName unchanged, and leave the :80 vhost alone (it still does the HTTP→HTTPS redirect and ACME renewals).

  • nginx — change listen 443 ssl; to listen 127.0.0.1:4443 ssl; (and drop any listen [::]:443), then nginx -t && systemctl reload nginx.
  • Apache — change Listen 443 in ports.conf to Listen 127.0.0.1:4443, and the vhost's <VirtualHost *:443> to <VirtualHost 127.0.0.1:4443>. A Listen change needs a full systemctl restart apache2 — a graceful reload does not rebind the socket.

Confirm 443 is free before MeshHold takes it:

sudo ss -ltnp 'sport = :443'     # must be empty
sudo ss -ltnp 'sport = :4443'    # your server, now on loopback

Then skip to step 2 with dest: "127.0.0.1:4443". The recipes below are for a site that isn't running yet.

Caddy

Least effort: it obtains and renews a Let's Encrypt certificate on its own.

# /etc/caddy/Caddyfile
yourdomain.com:4443 {
    root * /var/www/yoursite
    file_server
}

nginx

server {
    listen      127.0.0.1:4443 ssl;
    http2       on;                     # nginx < 1.25.1: put http2 on the listen line
    server_name yourdomain.com;

    ssl_certificate     /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

    # TLS 1.3 is required — the transport rejects a dest without it.
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;

    root  /var/www/yoursite;
    index index.html;
}

# Port 80 stays yours: HTTP-01 renewals and the canonical redirect.
server {
    listen      80;
    server_name yourdomain.com;

    location /.well-known/acme-challenge/ { root /var/www/certbot; }
    location / { return 301 https://$host$request_uri; }
}

Apache

Needs httpd ≥ 2.4.36 with OpenSSL 1.1.1+ for TLS 1.3.

# /etc/apache2/conf-available/meshhold-front.conf
Listen 127.0.0.1:4443

<VirtualHost 127.0.0.1:4443>
    ServerName yourdomain.com
    DocumentRoot /var/www/yoursite

    SSLEngine on
    SSLCertificateFile    /etc/letsencrypt/live/yourdomain.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/yourdomain.com/privkey.pem

    # TLS 1.3 is required — the transport rejects a dest without it.
    SSLProtocol -all +TLSv1.2 +TLSv1.3
    Protocols h2 http/1.1
</VirtualHost>

<VirtualHost *:80>
    ServerName yourdomain.com
    Alias /.well-known/acme-challenge/ /var/www/certbot/.well-known/acme-challenge/
    RedirectMatch 301 ^(?!/\.well-known/).*$ https://yourdomain.com$0
</VirtualHost>

Enable with a2enmod ssl http2 and a2enconf meshhold-front.

2. Point MeshHold at it

node:
  obfs:
    order: ["plain", "reality", "ssh"]
    reality:
      enabled: true
      port: 443                  # public HTTPS port visitors hit
      dest: "127.0.0.1:4443"     # your front server
      sni:  "yourdomain.com"     # required when dest is a local address

Restart the node after editing config.yaml.

Why sni matters

sni is the Server Name your peers put in their ClientHello when they dial this node. Left empty it is derived from dest, which is correct only when dest is a public hostname. With a local dest the derived name would be an IP literal — and TLS clients omit the SNI extension entirely for IP literals rather than sending the address.

Your peers would then arrive at 443 with no SNI while every real visitor arrives with yourdomain.com: a single, trivially filterable bit that undoes the masquerade. So the node refuses to start when dest names an address and sni is unset:

obfs: reality dest "127.0.0.1:4443" names no hostname, so peers would
dial with an empty SNI while real visitors send the front site's — set
node.obfs.reality.sni to the public domain that front site serves

Set it to the domain the front server's certificate covers. When dest is already a public hostname (dest: "example.com:443"), leave sni empty and it follows dest.

See the real visitor IP (proxy protocol)

By default the front server logs 127.0.0.1 for every visitor, because MeshHold relays the raw TCP stream with no client-address information. Enable proxy_protocol to prepend a PROXY-protocol header carrying the visitor's real address ahead of the forwarded handshake:

node:
  obfs:
    reality:
      enabled: true
      port: 443
      dest: "127.0.0.1:4443"
      sni:  "yourdomain.com"
      proxy_protocol: "v1"       # or "v2"; omit to disable

v1 is a human-readable line, v2 a compact binary block — pick whichever your front server prefers; both carry the same information.

The front server must be told to expect the header, and this is the catch: a server that isn't reads PROXY … as a broken TLS record and drops every visitor. So enable it on both sides together, and only against a front server you control — never an external dest, which would break outright.

nginx — add proxy_protocol to the listen line and trust the local relay:

server {
    listen      127.0.0.1:4443 ssl proxy_protocol;
    http2       on;
    server_name yourdomain.com;

    set_real_ip_from 127.0.0.1;        # trust the header from the local node
    real_ip_header   proxy_protocol;   # $remote_addr becomes the visitor
    # …certificate + root as above…
}

Apache — needs mod_remoteip (httpd ≥ 2.4.31); enable it inside the vhost:

<VirtualHost 127.0.0.1:4443>
    ServerName yourdomain.com
    RemoteIPProxyProtocol On
    # …certificate + DocumentRoot as above…
</VirtualHost>

Caddy accepts it with a listener_wrapper:

{
    servers 127.0.0.1:4443 {
        listener_wrappers { proxy_protocol }
    }
}

When dest points at an external site you don't run, leave proxy_protocol off — you can't configure the far end to expect it.

3. Point your domain at the node

Add a DNS A record for yourdomain.com to the node's public IPv4 address, so browsers reach it by name and the certificate matches.

The reality transport binds 0.0.0.0:443IPv4 only. Don't publish an AAAA record for the node unless you front IPv6 separately: it points IPv6 visitors at a port nothing listens on. Modern browsers recover by falling back to IPv4 (Happy Eyeballs), but with added latency, and IPv6-only clients fail outright.

4. Verify

# dest reachable + TLS-1.3 / X25519 / ALPN compatible.
# --sni presents your domain (as your peers will) instead of the dial IP,
# so the probe also reports whether the front cert is valid for it. It is
# required to get a meaningful cert check against a loopback dest: without
# it the probe would test the certificate against "127.0.0.1".
meshhold check-reality-dest --sni yourdomain.com 127.0.0.1:4443

# the full path, exactly as a browser would take it
curl -I --resolve yourdomain.com:443:<node-public-ip> https://yourdomain.com/

The curl line should return HTTP/… 200 with no certificate error. Then open https://yourdomain.com/ in a browser — your site loads with a valid padlock.

Certificates and renewal

MeshHold holds 443, so renewals must not expect to bind it themselves:

  • HTTP-01 (certbot's default for --nginx / --apache) uses port 80, which MeshHold never touches. Nothing special to do.
  • TLS-ALPN-01 works too — the challenge arrives at 443 and is spliced to the front server unchanged, so whichever server holds 4443 answers it. This is how Caddy renews on its own.
  • DNS-01 is unaffected.

Reload the front server after renewal as usual; MeshHold needs no restart, since it never reads the certificate.

What a visitor sees

  • By domain (https://yourdomain.com/, DNS → node) → the site loads with a valid padlock. The TLS session is end-to-end between the browser and your front server; the node only relays bytes.
  • By raw IP (https://<node-ip>/) → a certificate name-mismatch warning. That is normal for any HTTPS site hit by IP instead of by its hostname — the certificate is issued for a domain, not an address.
  • MeshHold peers on the same port still get the encrypted mesh. A browser can never reach it: without the peer auth marker it is always forwarded to dest.

Requirements & gotchas

  • The front server must terminate TLS itself with a CA-trusted certificate. A plain-HTTP server won't work; a self-signed certificate shows a browser warning.
  • The front server must speak TLS 1.3. Check any candidate with meshhold check-reality-dest <host:port> — add --sni yourdomain.com when the candidate is a loopback address, so the probe checks the certificate against your domain rather than the dial IP.
  • The public port reality binds (e.g. 443) and the front server's port (e.g. 4443) must differ, and the front server must not also try to bind the public one.
  • Visitor IPs are hidden by default. The splice is a plain TCP relay, so out of the box the front server's access log shows the node's local address for every visitor, and anything keyed on client IP — geo rules, rate limits, fail2ban — sees only that. Turn on proxy_protocol (both sides) to recover the real address.
  • SNI passes through untouched — the front server picks its virtual host and certificate from the SNI the client sends.
  • dest can also point at an external site — dest: "example.com:443" — instead of a local one. The node then relays visitors straight to that site, with no local server to run, and sni can stay empty.