feat: support remote compilation over SSH tunnels - #103
Conversation
There was a problem hiding this comment.
Copilot wasn't able to review this pull request because all changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.
Oliver Layer (OliLay)
left a comment
There was a problem hiding this comment.
Left some minor comments. I like the approach since we do not teach homccd how to handle SSH. 👍
| ## Deployment hints | ||
| Things to keep in mind when deploying `homccd`: | ||
| - `homcc` currently does not support any transport encryption such as TLS, so source files would get transmitted over the internet in plain text if not using a VPN. | ||
| - `homcc` does not support built-in transport encryption such as TLS: plain TCP hosts transmit source files unencrypted, so a VPN is required over untrusted networks. Alternatively, use an SSH host (`@HOST`/`USER@HOST`) to tunnel the connection through an encrypted, authenticated SSH channel to a `homccd` bound to the remote loopback interface. |
There was a problem hiding this comment.
I think we can remove this now?
There was a problem hiding this comment.
Left it — it's not just about TLS, the other two bullets (no per-client connection limit, no restriction on docker/chroot selection) still hold regardless of transport. Reworded it instead so it doesn't imply VPN is the only mitigation now that SSH tunneling is an option too: 9102cd4
| @classmethod | ||
| def get_ssh_options(cls) -> Optional[List[str]]: | ||
| if (ssh_options := os.getenv(cls.HOMCC_SSH_OPTIONS_ENV_VAR)) is not None: | ||
| return ssh_options.split() |
There was a problem hiding this comment.
I think this doesn't work with spaces in SSH options, does it? Maybe https://docs.python.org/3/library/shlex.html#shlex.split could work here?
There was a problem hiding this comment.
Good catch, fixed with shlex.split in both the env var and config-file parsing paths. 9102cd4.
| def close(self): | ||
| """Tear down the multiplexed master connection. Idle masters are otherwise reaped via ControlPersist.""" | ||
| subprocess.run( # noqa: PLW1510 | ||
| [*self._control_args(), "-O", "exit", self.target], | ||
| stdout=subprocess.DEVNULL, | ||
| stderr=subprocess.DEVNULL, | ||
| check=False, | ||
| ) | ||
| self._port_file.unlink(missing_ok=True) |
There was a problem hiding this comment.
I couldn't quite figure out how this method is used. Where is it called? How is this triggered?
There was a problem hiding this comment.
Traced it — it's actually dead code, nothing calls it (masters are reaped by ControlPersist instead). Removed it in 9102cd4. Shout if there was an intended caller I'm missing (e.g. explicit teardown on client exit) and I'll bring it back.
Add SSH as a first-class connection type alongside TCP. SSH hosts (@host / USER@HOST) are served by tunneling to an already running homccd: a multiplexed OpenSSH master (ControlMaster/ControlPersist) local-forwards a port to the daemon's loopback port, and the existing protocol runs over that port. All jobs reach the same daemon, so its global connection limit covers TCP and SSH alike, and per-job latency stays close to a plain TCP connect. - Extract transport-agnostic RemoteCompilationClient base from TCPClient; add SSHClient and SSHTunnel (client/ssh.py) - Dispatch on host.type via create_remote_client factory - Parse optional remote daemon port for SSH hosts (@host:port, user@host:port, @[ipv6]:port) - Add SSHError(ConnectionError) so tunnel failures fall through to the next host / local fallback - Wire ssh_executable / ssh_control_persist / ssh_options config - Serialize per-host master setup with an flock so concurrent homcc processes converge on one tunnel - Tests: tunnel logic, dispatch, SSH port parsing, config, and an opt-in e2e test behind --runssh; document in README and CLAUDE.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2e14566 to
8a9c3f1
Compare
- ssh.py: catch OSError/CalledProcessError separately in _start_master instead of a manual returncode check, so a missing ssh executable is reported distinctly; reuse common.parsing.default_locations for the ssh control-socket directory; make control_args a public property; switch to asyncio.to_thread; drop the unused SSHTunnel.close() - config.py: parse HOMCC_SSH_OPTIONS/ssh_options with shlex.split so quoted values with spaces work - README.md: clarify that SSH tunneling is also a mitigation for the no-built-in-encryption deployment hint - add AGENTS.md as a symlink to CLAUDE.md Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Summary
Adds SSH as a first-class connection type alongside TCP. Hosts written as
@HOST/USER@HOSTare served by tunneling to an already runninghomccd: a multiplexed OpenSSH master connection (ControlMaster/ControlPersist) local-forwards a port to the daemon's loopback port, and the existing homcc protocol runs over that port.This model was chosen to satisfy three constraints:
Changes
homcc/client/client.py): extractRemoteCompilationClientbase holding all protocol logic;TCPClientbecomes a thin subclass.homcc/client/ssh.py, new):SSHTunnel(master lifecycle, local port-forward, per-hostflockso the many concurrenthomccprocesses a build spawns converge on one tunnel) andSSHClient.homcc/client/compilation.py):create_remote_clientpicks the transport fromhost.type.homcc/common/host.py): SSH hosts accept an optional remote daemon port —@host:port,user@host:port,@[ipv6]:port.homcc/common/errors.py):SSHError(ConnectionError)so tunnel failures fall through to the next host / local fallback like any lost connection.homcc/client/config.py):ssh_executable,ssh_control_persist,ssh_optionsvia env vars andhomcc.conf.tests/client/ssh_test.py(tunnel logic, dispatch, error paths), SSH port-parsing tests, config tests, and an opt-in e2e test behind--runssh.CLAUDE.md.No server changes are required.
Testing
sshcommand line was validated against the realsshbinary (fails on connection, not usage → syntax correct).SSHClientshares the entire protocol path withTCPClient(only_open_connectiondiffers, connecting to a local port), which is covered by the existing TCP e2e tests.pytest --runsshin an environment with ansshdreachable on the local host.Notes
3126(matching TCP) when unspecified.🤖 Generated with Claude Code