luna chooses SSH jump hosts based on where your machine is.
Say you have a server ofbox in the office. At the office you connect to it directly. At home it is
only reachable through the office gateway ofgw, so you type ssh -J ofgw ofbox, or keep two
Host entries and remember which one to use. With luna you describe the networks once in
zone.ini, and ssh ofbox takes the right path wherever you are. Before each connection luna
reads the local timezone and network interfaces to find out which networks the machine is in,
computes the shortest path to the destination, and turns it into a ProxyJump chain.
- OpenSSH and Bash. On Windows, Git for Windows provides both (see the notes in config).
- Python 3.14 or newer, run as the
python3(orpython) found onPATH. The scripts leave any active virtualenv before starting it. - Optionally
richfor colored diagnostics, andnetifaces-plusfor faster network detection. Install them into that same interpreter. They are also declared as therichandnetifacesextras. On macOS a zone with asubnetneedsnetifaces-plus, since luna otherwise reads the interfaces fromip addr.
git clone https://github.com/karin0/luna.git ~/.ssh/lunaWrite ~/.ssh/zone.ini. Each section is a zone, a group of hosts that can reach each other
directly.
[home]
host = gw1 box1
arc = ofgw:office
[office]
host = ofgw ofbox
subnet = 192.168.1.0/24The home zone has no condition, so luna always treats the machine as being in it. The office
zone applies when a local interface is on 192.168.1.0/24. arc = ofgw:office says that from
home, the office zone is reached by jumping through ofgw. At home ssh ofbox becomes a jump
through ofgw, and at the office both zones apply, so it connects directly.
The names in host are the Host names from your SSH config. The full syntax is in
zone.ini reference.
Generator mode keeps an include file for ~/.ssh/config up to date, so every program that reads
the SSH config (git, rsync, scp, editors with remote extensions) takes the routes without
further setup. It reads only the Host blocks of your config, so choose it when your config is
made of Host blocks.
Wrapper mode puts a script named ssh in front of the real one and rewrites its command line,
unless the command line already names a jump with -J, -o ProxyJump or -o ProxyCommand. It
leaves the config files alone, so it suits configs that depend on Match or Include. Programs
only benefit when they find the wrapper on PATH.
mv ~/.ssh/config ~/.ssh/sshconfig
cp ~/.ssh/luna/config ~/.ssh/configYour own config now lives in ~/.ssh/sshconfig, and that is the file you edit from here on.
~/.ssh/config holds only the two lines copied from config. Before each connection its
Match exec line runs install.sh, which regenerates ~/.ssh/config.inc, and the
Include line loads that file. The generated file is a copy of sshconfig with the jump options
added in front, and its DO NOT EDIT header names the git revisions of luna and of sshconfig.
Put ssh.sh on PATH under the name ssh, ahead of the real one, and set the variables
below. Use exact paths, since the wrapper runs LUNA_SSH itself.
ln -s ~/.ssh/luna/ssh.sh ~/.local/bin/ssh
export LUNA_SSH=/usr/bin/ssh| Variable | Default | Meaning |
|---|---|---|
LUNA_SSH |
ssh |
The real ssh executable. |
LUNA_ENTRY |
~/.ssh/luna/luna.py |
This repository's luna.py. |
LUNA_ZONE |
~/.ssh/zone.ini |
The zone file. |
LUNA_CONFIG |
empty | An SSH config, read only to find hosts by subnet (see strict-host). |
luna prints the routes it chose on standard error when you connect.
# [] -> {home: gw1, box1} (0)
# [ofgw] -> {office: ofgw, ofbox} (20)
Each line is a zone, the jumps that lead to it, and its total cost. In generator mode ssh -G
shows the options a connection would use, which runs luna as well.
ssh -G ofbox | grep -i proxyjumpIn wrapper mode the following command prints the rewritten command, ssh -J ofgw ofbox, without
running it.
~/.ssh/luna/luna.py -p -z ~/.ssh/zone.ini -- ofboxPoint the remote.SSH.configFile setting at ~/.ssh/config.inc. By default it reads
~/.ssh/config, which holds only the Match exec block that includes config.inc, and then lists
no hosts. Remote - SSH lists the names on Host lines and leaves Match blocks out, so luna heads
the blocks it adds, the d. hosts and the routes, with Match originalhost, which ssh matches like
Host. It heads the blocks of sshconfig that name only zone aliases the same way, so the list
shows each machine once.
Remote - SSH connects with ssh -F on that file, which leaves ~/.ssh/config and its Match exec
line unread, so its connections do not regenerate anything. They use the routes from the last ssh
run elsewhere. After moving to another network, run any ssh command once before connecting from
VS Code.
A program that parses SSH configs itself can read config.inc only if its parser supports
Match originalhost. For other parsers, this command prints a copy with those blocks headed by
Host, which ssh resolves the same way.
sed -E '/^Match originalhost [^ #]+( +#.*)?$/{s/^Match originalhost //;s/,/ /g;s/^/Host /;}' ~/.ssh/config.incThe copy keeps the routes it was made with, so make it again after an ssh run on another network.
For every host name in zone.ini, ssh d.name connects to it directly without any jumps, in
both modes.
Setting LUNA_SSH_DIRECT=1 turns routing off for one command. In generator mode install.sh then
writes a plain copy of sshconfig to config.inc. The wrapper exports this variable itself, so an
ssh started from inside a connection, such as one in a ProxyCommand, runs unrouted.
If luna fails in generator mode, ssh prints Luna failed, trying the previous config and
connects with the config.inc from the last successful run.
All keys of a zone are optional.
host lists the hosts in the zone. An entry name:alias... gives a host extra names. An alias is
another address of the same host that other zones can jump to, such as a public address, so it may
be unreachable from the host's own zone.
timezone and subnet decide whether the machine is in the zone. luna checks them against local
state alone, since probing a host would add a timeout to every connection, and a timeout short
enough to go unnoticed misreads a slow overseas link as unreachable. When timezone is set, the
current local UTC offset must equal it in hours, so in a region with daylight saving time the zone
applies for only part of the year. When subnet is set, one of the listed networks must be the
network of a local interface, or contain a gateway when netifaces-plus is installed and
LUNA_STRICT_SUBNET is unset. Any other network that uses the same private range matches too. A
zone with neither key always applies, and every zone that applies is a starting point for routing.
arc lists one-way links from this zone, each in one of these forms.
| Form | Meaning |
|---|---|
via:zone:cost |
Jump through via into zone. |
via:zone |
Same, with the default cost of 20. |
zone or zone:cost |
zone is reachable without a jump. |
via or via:cost |
Jump through via into the zone that via belongs to. |
via is a host or an alias from host, or any other hostname when the target zone is written out.
Reaching a host inside a zone adds a cost of 10.
strict-host = true stops luna from adding hosts to the zone by itself. Otherwise every host in
the SSH config whose Hostname is an IPv4 address inside a zone's subnet joins that zone, and the
hosts whose names start with its name become its aliases.
install.sh runs luna in ~/.ssh and skips regeneration when luna wrote config.inc in the
last two seconds, or when neither sshconfig, zone.ini nor luna's own sources changed and the
network state recorded in config.inc.state still holds. Concurrent connections take turns on
config.inc.lock, and a connection that had to wait uses the file the previous one wrote. luna
parses the Host and Match originalhost blocks of sshconfig itself, because resolving each host
with ssh -G would run every Match exec in sshconfig once per host on each regeneration.
Options applied by other Match blocks or Include inside sshconfig therefore do not reach the
generated routes.
install.sh accepts -c <dir> (working directory), -i <file> (input config, default
sshconfig) and -o <file> (output, default ~/.ssh/config.inc).
By default luna prints only the routes to the destination and the errors. LUNA_VERBOSE adds the
rest, such as the destination after %h substitution, the detected network state and the options
the destination ends up with. LUNA_MUTE silences everything, and MOON_TRACE adds timings to the
verbose output. In generator mode the full diagnostics are also written into config.inc as
comments.
uv sync --all-extras
uv run ruff check
uv run ruff format --check
uv run pyright
uv run pytestThe top-level modules (luna.py, lib.py, cfg.py) hold the command line and zone policy.
moon/ is the library they build on (the ssh_config parser, the routing graph, interface
detection and the lock), and it never imports from the top level. luna.py imports lazily because
Match exec starts it on every connection.