<p align="center">
<img width="614" height="409" alt="fusion" src="https://github.com/user-attachments/assets/d4cdbf66-658c-46d0-8f43-99941f18101d" />
</p>
# Fusion
Remote task runner using Erlang distribution over SSH. Zero dependencies.
Fusion connects to remote servers via SSH, sets up port tunnels for Erlang distribution, bootstraps a remote BEAM node, and lets you run Elixir code on it. Think Ansible/Chef but for Elixir - push modules and execute functions on remote machines without pre-installing your application.
[](https://hex.pm/packages/fusion)
[](https://hexdocs.pm/fusion)
## Articles
- [Running Elixir on Remote Servers with Fusion](https://eyallapid.me/blog/running-elixir-on-remote-servers-with-fusion)
- [How Fusion Works: Tunnels and Distribution](https://eyallapid.me/blog/how-fusion-works-tunnels-and-distribution)
- [How Fusion Works: Bytecode Pushing](https://eyallapid.me/blog/how-fusion-works-bytecode-pushing)
## Requirements
- Elixir ~> 1.18 / OTP 28+
- Remote server with Elixir/Erlang installed
- SSH access (key-based or password)
## Installation
Add `fusion` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:fusion, "~> 0.2.0"}
]
end
```
## Usage
Your local BEAM must be started as a distributed node:
```bash
iex --sname myapp@localhost -S mix
```
Then connect and run code remotely:
```elixir
# Define the target
target = %Fusion.Target{
host: "10.0.1.5",
port: 22,
username: "deploy",
auth: {:key, "~/.ssh/id_ed25519"}
}
# Connect (sets up tunnels, bootstraps remote BEAM, joins cluster)
{:ok, manager} = Fusion.NodeManager.start_link(target)
{:ok, remote_node} = Fusion.NodeManager.connect(manager)
```
Run functions on the remote:
```elixir
# Get remote system info
{:ok, version} = Fusion.run(remote_node, System, :version, [])
{:ok, {hostname, 0}} = Fusion.run(remote_node, System, :cmd, ["hostname", []])
```
Run anonymous functions directly:
```elixir
{:ok, info} = Fusion.run_fun(remote_node, fn ->
%{
node: Node.self(),
otp: System.otp_release(),
os: :os.type()
}
end)
```
Push and run your own modules — dependencies are resolved automatically:
```elixir
defmodule RemoteHealth do
def check do
%{
hostname: hostname(),
elixir_version: System.version(),
memory_mb: memory_mb()
}
end
defp hostname do
{name, _} = System.cmd("hostname", [])
String.trim(name)
end
defp memory_mb do
{meminfo, _} = System.cmd("cat", ["/proc/meminfo"])
meminfo
|> String.split("\n")
|> Enum.find(&String.starts_with?(&1, "MemTotal"))
|> String.split(~r/\s+/)
|> Enum.at(1)
|> String.to_integer()
|> div(1024)
end
end
{:ok, health} = Fusion.run(remote_node, RemoteHealth, :check, [])
# => %{hostname: "web-01", elixir_version: "1.18.4", memory_mb: 7982}
```
Disconnect when done:
```elixir
Fusion.NodeManager.disconnect(manager)
```
### SSH Backend
Fusion uses Erlang's built-in SSH module by default. No system `ssh` binary required.
To use the legacy system SSH backend instead:
```elixir
target = %Fusion.Target{
host: "10.0.1.5",
username: "deploy",
auth: {:key, "~/.ssh/id_ed25519"},
ssh_backend: Fusion.SshBackend.System # uses system ssh/sshpass
}
```
### Automatic Dependency Resolution
When you run `RemoteHealth` remotely, Fusion reads the BEAM bytecode, walks the dependency tree, and pushes everything the module needs. You don't need to manually track the dependency chain.
```elixir
# You can also push modules explicitly
Fusion.TaskRunner.push_module(remote_node, MyApp.Worker)
Fusion.TaskRunner.push_modules(remote_node, [MyApp.Config, MyApp.Utils])
```
Standard library modules (Kernel, Enum, String, etc.) are already on the remote and don't need pushing.
## How It Works
### 1. SSH Tunnel Setup
Fusion creates 3 SSH tunnels between local and remote:
```
Local Machine Remote Server
───────────── ─────────────
┌─── Reverse ────┐
Local node port ◄┘ tunnel #1 └── Remote can reach local node
┌─── Forward ────┐
localhost:port ──┘ tunnel #2 └► Remote node's dist port
┌─── Reverse ────┐
Local EPMD ◄─┘ tunnel #3 └── Remote registers with local EPMD
(port 4369)
```
### 2. Remote BEAM Bootstrap
Starts Elixir on the remote via SSH with carefully configured flags:
- `ERL_EPMD_PORT=<tunneled>` - routes EPMD registration through tunnel #3 back to local EPMD
- `--sname worker@localhost` - uses `@localhost` because all traffic goes through localhost-bound tunnels
- `--cookie <local_cookie>` - matches the local cluster's cookie
- `--erl "-kernel inet_dist_listen_min/max <port>"` - pins distribution port to match tunnel #2
### 3. Transparent Connection
Since the remote registered with the *local* EPMD, `Node.connect/1` works as if the remote node were local. All distribution traffic is routed through the SSH tunnels.
### 4. Code Pushing
Module bytecode is transferred via Erlang distribution:
1. Read `.beam` binary locally with `:code.get_object_code/1`
2. Parse BEAM atoms table to find non-stdlib dependencies
3. Push each dependency recursively (bottom-up)
4. Load on remote with `:code.load_binary/3`
5. Execute via `:erpc.call/4`
## Testing
```bash
# Unit tests (no external dependencies)
mix test
# Docker integration tests (requires Docker)
cd test/docker && ./run.sh start
elixir --sname fusion_test@localhost -S mix test --include external
# Stop the test container
cd test/docker && ./run.sh stop
```
### Test Tiers
- **Tier 1 (Unit)** - Doctests and pure logic tests. No network, no SSH.
- **Tier 2 (Integration)** - Tests against localhost SSH. Skips gracefully if not configured.
- **Tier 3 (External)** - End-to-end tests against a Docker container with SSH + Elixir. Requires `./run.sh start`.
## Architecture
```
Fusion (public API)
├── TaskRunner - Remote code execution + module pushing + dependency resolution
├── NodeManager - GenServer: tunnel setup, BEAM bootstrap, connection lifecycle
├── Target - SSH connection configuration struct (includes ssh_backend selection)
├── SshBackend - Behaviour for pluggable SSH implementations
│ ├── Erlang - Default: uses OTP's built-in :ssh module
│ └── System - Legacy: shells out to system ssh/sshpass binaries
├── SshKeyProvider - Custom ssh_client_key_api for specific key file paths
├── TunnelSupervisor - DynamicSupervisor for tunnel processes
├── Net - Port generation, EPMD utilities
├── Connector - SSH connection GenServer
├── SshPortTunnel - SSH port tunnel process wrapper
├── PortRelay - Port relay process wrapper
├── UdpTunnel - UDP tunnel process wrapper
└── Utilities
├── Ssh - SSH command string generation
├── Exec - OS process execution (Port/System.cmd)
├── Erl - Erlang CLI command builder
└── Bash/Socat/Netcat/Netstat/Telnet - CLI tool wrappers
```
## License
MIT