Reference

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:

  • fail2ban against sshd — it will ban 127.0.0.1 or nothing
  • Match Address blocks in sshd_config
  • AllowUsers user@host patterns
  • auth.log as 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