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;tolisten 127.0.0.1:4443 ssl;(and drop anylisten [::]:443), thennginx -t && systemctl reload nginx. - Apache — change
Listen 443inports.conftoListen 127.0.0.1:4443, and the vhost's<VirtualHost *:443>to<VirtualHost 127.0.0.1:4443>. AListenchange needs a fullsystemctl restart apache2— a gracefulreloaddoes 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:443 — IPv4 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 port80, which MeshHold never touches. Nothing special to do. - TLS-ALPN-01 works too — the challenge arrives at
443and is spliced to the front server unchanged, so whichever server holds4443answers 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.comwhen the candidate is a loopback address, so the probe checks the certificate against your domain rather than the dial IP. - The public
portreality 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.
destcan 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, andsnican stay empty.