Run MeshHold in front of your sshd
Share port 22 between the mesh and a real SSH server — the config, the banner rule, and what you give up.
The SSH transport lets a node accept mesh traffic on a port that is
already carrying something else — normally 22, the one port a
restrictive network almost always passes. MeshHold owns the port and
decides per connection: peers that authenticate get the mesh, everyone
else is handed to your real sshd.
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 OpenSSH server and nothing else.
Enabling the transport itself is in The Private Mesh VPN. This page is about putting a real sshd behind it.
How the port is shared
| Client | What happens |
|---|---|
| MeshHold peer | Auth frame verifies → the node keeps the connection and the mesh rides on it. Your sshd never sees it. |
| Anyone else | No valid frame → the raw TCP connection is spliced to dest, and they get a genuine SSH session. |
The splice is byte-for-byte. MeshHold runs no key exchange for visitors, holds no host key, and never sees their session — the handshake they complete is with your sshd.
Without a dest the transport still works: non-members get a
synthetic KEXINIT + DISCONNECT that looks like an sshd refusing a
key exchange. That is enough for a passive scan, but a prober that
tries to finish the exchange finds nobody home. A real backend
removes the difference.
1. Move sshd off the public port
MeshHold needs 22, so sshd moves to a loopback port. In
/etc/ssh/sshd_config:
ListenAddress 127.0.0.1:2222
Do this from a session you keep open, and open a second one to confirm before you close the first. Getting locked out of a remote machine is the one real risk in this setup.
sudo systemctl restart ssh
ssh -p 2222 you@127.0.0.1 # from the open session — must work
sudo ss -ltnp 'sport = :22' # must be empty
If the host runs fail2ban against sshd, read
what you give up before continuing — it stops
working once traffic arrives over loopback.
2. Point MeshHold at it
node:
obfs:
ssh:
enabled: true
port: 22
dest: "127.0.0.1:2222"
order: ["plain", "reality", "ssh"]
Check the backend before you restart the daemon:
meshhold check-ssh-dest 127.0.0.1:2222
It reports reachability, that the backend greets first the way an sshd does, and the identification string the mask will serve.
Then restart the daemon and confirm both roles on the one port:
ssh -p 22 you@<host> # reaches your sshd through the mask
meshhold status # peers connected over obfs-sshmask
The banner is not cosmetic
MeshHold serves your backend's identification string verbatim — not a configured one, and not a default.
It has no choice. RFC 4253 §8 feeds both identification strings into the exchange hash the server signs with its host key, so a client that saw one string while sshd signed over another fails signature verification. One byte of difference breaks every login through the mask.
So the daemon reads the string from the backend: at startup, about once
a day after that, and immediately whenever a forwarded session finds it
changed. Upgrading openssh-server therefore needs no daemon restart —
the first login after the upgrade may fail, and the next one works.
node.obfs.ssh.banner still exists, but with dest set it is only the
fallback for the window where the backend is unreachable. Setting it to
something the backend doesn't say buys nothing; leave it empty.
What you give up
sshd stops seeing the visitor's IP. Forwarded sessions arrive from the loopback dial, because OpenSSH speaks no PROXY protocol — there is no way to pass the real address through. Everything on the backend that keys on client address stops working:
fail2banagainst sshd — it will ban127.0.0.1or nothingMatch Addressblocks insshd_configAllowUsers user@hostpatternsauth.logas a forensic record — every line reads as loopback
Move per-IP policy to MeshHold's own rate limiting, or accept the loss.
Key-based auth, PermitRootLogin, AllowUsers user without a host
part, and everything else that doesn't look at the source address are
unaffected.
When ssh through the mask just drops
The visitor sees the connection close right after the version exchange:
Remote side unexpectedly closed network connection in PuTTY,
Connection closed by remote host in OpenSSH. There is no SSH-level
error because the mask has nothing to say in SSH — it gave up before
the backend's key exchange.
A close roughly 5 seconds after the banner is the backend dial timing out. The mask logs the reason (throttled to one line a minute per cause, so a scanned port doesn't flood):
meshhold diagnostics logs | grep sshmask
| Log line | What happened |
|---|---|
ssh backend unreachable |
Nothing accepted a connection at dest. sshd stopped, moved port, or something is filtering the address. |
ssh backend sent no identification string |
Something is listening at dest, but it isn't an sshd. |
ssh backend banner changed mid-flight |
sshd was upgraded between connections. Self-healing — the next attempt works. |
too many forwarded ssh sessions in flight |
More than 256 concurrent forwarded sessions. |
One cause worth naming, because it is easy to inflict on yourself:
fail2ban on the backend host will ban the machine running the
mask. Every forwarded session arrives from the same address, so a few
failed logins through the mask are enough to get that address blocked
for everyone using it — and the symptom is an unreachable backend, not
an auth error. Check whether the mask's host can still reach dest
directly:
meshhold check-ssh-dest <dest>
What this does and does not do
It genuinely gives you:
- No extra listening port. The mesh shares one you already expose.
- Cryptographic pre-authentication. Without the network's key, the mesh listener does not answer at all — a visitor gets your sshd and no evidence the node exists. Mass scanners index it as OpenSSH.
- A smaller reachable surface. A flaw in the mesh handshake is not reachable before authentication, so it isn't exposed to internet-wide scanning.
It does not:
- Protect a node whose key has leaked. Anyone holding the network key is a member; the mask gates strangers, not peers.
- Harden your sshd. That box is exactly as exposed as it was, and now without per-IP defences. Key-only auth is worth doing here.
- Reduce software on the machine. You are running a mask in front of a server, not instead of one.
- Hide who you talk to. An observer still sees your address exchanging traffic with the other end.
As a side effect, the connection is shaped like an ordinary SSH session on the wire, which is what lets it survive networks that drop protocols they don't recognise. Whether you may use that on a given network is between you and whoever runs it — see Acceptable Use.
See also
- Custom obfs-https front site — the same
arrangement on
443, in front of a web server. - The Private Mesh VPN — enabling the transports.