Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,24 @@ builds:
- *strip_ldflags
tags:
- full
- id: plugin_revtunnel
env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
goarch:
- amd64
- arm64
main: ./plugin/revtunnel
binary: plugins/revtunnel
flags:
- *trimpath_flag
ldflags:
- *strip_ldflags
tags:
- full
- id: plugin_failtoban
env:
- CGO_ENABLED=0
Expand Down Expand Up @@ -274,6 +292,7 @@ archives:
- plugin_username_router
- plugin_lua
- plugin_metrics
- plugin_revtunnel
- sshpiperd_webadmin
- sshpiperd_admin
dockers:
Expand Down Expand Up @@ -393,6 +412,7 @@ snapcrafts:
- plugin_failtoban
- plugin_username_router
- plugin_lua
- plugin_revtunnel
name: sshpiperd
name_template: "sshpiperd_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
summary: The missing reverse proxy for ssh scp
Expand Down
Binary file added e2e/bench-profiles/bench-cpu.prof
Binary file not shown.
Binary file added e2e/bench-profiles/bench-mem.prof
Binary file not shown.
147 changes: 147 additions & 0 deletions e2e/revtunnel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package e2e_test

import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"path"
"regexp"
"strings"
"testing"
"time"

"github.com/google/uuid"
)

// TestRevtunnel exercises plugin/revtunnel end-to-end:
//
// 1. start sshpiperd with the revtunnel plugin
// 2. open `ssh -R 0:host-publickey:2222 -i <key> user@piper` to register a
// tunnel; read the GUID + upstream public key from the session output
// 3. install the upstream public key on host-publickey's authorized_keys
// 4. run `ssh -i <same_key> <guid>@piper '<remote cmd>'` and verify the
// command runs on host-publickey through the reverse tunnel
func TestRevtunnel(t *testing.T) {
piperaddr, piperport := nextAvailablePiperAddress()

piper, _, _, err := runCmd("/sshpiperd/sshpiperd",
"-p", piperport,
"/sshpiperd/plugins/revtunnel",
)
if err != nil {
t.Fatalf("failed to run sshpiperd: %v", err)
}
defer killCmd(piper)
waitForEndpointReady(piperaddr)

keydir, err := os.MkdirTemp("", "revtunnel-*")
if err != nil {
t.Fatalf("mkdtemp: %v", err)
}
defer os.RemoveAll(keydir)

// Write the registrar's identity key (same key used for both register and connect).
registrarKeyPath := path.Join(keydir, "id_registrar")
if err := os.WriteFile(registrarKeyPath, []byte(testprivatekey), 0o400); err != nil {
t.Fatalf("write registrar key: %v", err)
}

// 1) Launch the registrar — uses pubkey auth with the test key.
registrar, regStdin, regStdout, err := runCmd(
"ssh",
"-tt",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "IdentitiesOnly=yes",
"-i", registrarKeyPath,
"-o", "ExitOnForwardFailure=yes",
"-p", piperport,
"-R", "0:host-publickey:2222",
"user@127.0.0.1",
)
if err != nil {
t.Fatalf("ssh -R: %v", err)
}
defer killCmd(registrar)
_ = regStdin

guid, upstreamPub, err := readRegistration(regStdout, 15*time.Second)
if err != nil {
t.Fatalf("read registration: %v", err)
}
t.Logf("registered guid=%s upstream_pub=%s", guid, strings.TrimSpace(upstreamPub))

// Install the upstream public key on the target host.
if err := os.WriteFile(authorizedKeysPath, []byte(upstreamPub+"\n"), 0o400); err != nil {
t.Fatalf("write authorized_keys: %v", err)
}

// 2) Connect through the tunnel using the registrar's own identity key.
randtext := uuid.New().String()
targetfile := uuid.New().String()
remoteCmd := fmt.Sprintf(`sh -c "echo -n %s > /shared/%s"`, randtext, targetfile)

c, _, _, err := runCmd(
"ssh",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "IdentitiesOnly=yes",
"-i", registrarKeyPath,
"-p", piperport,
guid+"@127.0.0.1",
remoteCmd,
)
if err != nil {
t.Fatalf("ssh connect: %v", err)
}
if err := c.Wait(); err != nil {
t.Fatalf("ssh connect exit: %v", err)
}

time.Sleep(time.Second) // flush
checkSharedFileContent(t, targetfile, randtext)
}

// readRegistration polls the registrar session's stdout until it has parsed
// the GUID and upstream public key emitted by plugin/revtunnel.
var (
reGUID = regexp.MustCompile(`^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$`)
reUpstreamPub = regexp.MustCompile(`^echo '(ssh-[^ ']+ [^ ']+)' >> ~/\.ssh/authorized_keys$`)
)

func readRegistration(r io.Reader, timeout time.Duration) (guid, upstreamPub string, err error) {
buf, ok := r.(*bytes.Buffer)
if !ok {
return "", "", fmt.Errorf("readRegistration: expected *bytes.Buffer, got %T", r)
}

deadline := time.Now().Add(timeout)
for {
guid, upstreamPub, ok := parseRegistration(buf.Bytes())
if ok {
return guid, upstreamPub, nil
}
if time.Now().After(deadline) {
return "", "", fmt.Errorf("timed out after %s; partial data: guid=%q pub=%q", timeout, guid, upstreamPub)
}
time.Sleep(250 * time.Millisecond)
}
}

func parseRegistration(data []byte) (guid, upstreamPub string, ok bool) {
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(make([]byte, 4096), 1<<20)

for scanner.Scan() {
line := strings.TrimRight(scanner.Text(), "\r")
switch {
case reGUID.MatchString(line):
guid = reGUID.FindStringSubmatch(line)[1]
case reUpstreamPub.MatchString(line):
upstreamPub = strings.TrimSpace(reUpstreamPub.FindStringSubmatch(line)[1])
}
}
return guid, upstreamPub, guid != "" && upstreamPub != ""
}
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ This folder contains minimal demos for new users to try `sshpiper` with Docker C
- [`lua-publickey-git-routing`](./lua-publickey-git-routing): use Lua publickey callback routing to proxy SSH git clone requests to two different upstream git SSH servers.
- [`honeypot-on-failure`](./honeypot-on-failure): route logins to a real sshd when the password is correct and silently redirect everything else to a [`cowrie`](https://github.com/cowrie/cowrie) SSH honeypot.
- [`webadmin`](./webadmin): browser dashboard (`sshpiperd-webadmin`) for live session viewing and kill, fronting an `sshpiperd` instance with the admin gRPC API enabled.
- [`revtunnel`](./revtunnel): reverse-tunnel plugin — register an SSH tunnel with `ssh -R` and let others connect through it using the assigned GUID.
69 changes: 69 additions & 0 deletions examples/revtunnel/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Revtunnel Plugin — Local Demo

This compose file starts sshpiperd with the revtunnel plugin and a target
OpenSSH server (user: `user`, password: `pass`).

## Start

```bash
cd examples/revtunnel
docker compose up
```

Wait until you see `sshpiperd is listening on [::]:2222`.

## Step 1 — Register a tunnel

In a **new terminal**, register using your existing SSH key.
The SSH username you connect with becomes the **target username** for the upstream:

```bash
ssh -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-R 0:target:2222 \
-p 2222 user@127.0.0.1
```

You'll see output like:

```
a1b2c3d4-e5f6-7890-abcd-ef1234567890

echo 'ssh-ed25519 AAAA...' >> ~/.ssh/authorized_keys

# connect with:
ssh a1b2c3d4-e5f6-7890-abcd-ef1234567890@localhost -p 2222 # -> user@target:2222

# press Ctrl+C to stop forwarding
```

Comment thread
tg123 marked this conversation as resolved.
Keep this terminal open — the tunnel stays alive as long as the connection is active.

## Step 2 — Install the upstream key on the target

Copy the `echo '...' >> ~/.ssh/authorized_keys` line and run it on the target.
In this demo the target container uses `/publickey_authorized_keys/authorized_keys`:

```bash
docker compose exec target sh -c 'echo "ssh-ed25519 AAAA..." >> /etc/ssh/authorized_keys'
```

## Step 3 — Connect through the tunnel

Use the same SSH key you used in Step 1:

```bash
ssh -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o IdentitiesOnly=yes \
-i ~/.ssh/id_ed25519 \
-p 2222 <GUID>@127.0.0.1
```

You're now connected to the target container via the reverse tunnel! 🎉

## Teardown

```bash
docker compose down -v
```
35 changes: 35 additions & 0 deletions examples/revtunnel/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
services:
sshpiperd:
build:
context: ../..
target: sshpiperd
args:
BUILDTAGS: full
command:
- /sshpiperd/plugins/revtunnel
- --session-store
- file:///tmp/revtunnel-sessions
- --piper-host
- localhost
- --piper-port
- "2222"
ports:
- "2222:2222"
depends_on:
target:
condition: service_healthy

# A plain sshd the registrar exposes via the tunnel.
# user: user, password: pass
target:
image: lscr.io/linuxserver/openssh-server:latest
environment:
- USER_NAME=user
- USER_PASSWORD=pass
- PASSWORD_ACCESS=true
- SUDO_ACCESS=true
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "2222"]
interval: 2s
timeout: 2s
retries: 10
76 changes: 76 additions & 0 deletions plugin/revtunnel/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# revtunnel plugin for sshpiperd

Register an SSH reverse tunnel with `ssh -R` and let others connect
through sshpiperd using the assigned GUID. Authentication is based on
the registrar's own SSH public key — no separate private key is issued.

## How it works

1. **Register.** The registrar runs:
```
ssh -R 0:<host>:<port> <username>@sshpiper
```
`<host>:<port>` is reachable from the registrar (typically the
registrar's own sshd, e.g. `localhost:22`). `<username>` becomes the
SSH user for upstream auth on the target host.

The plugin authenticates the registrar with their SSH public key,
accepts the `tcpip-forward` global request, generates a fresh GUID and
an internal ed25519 keypair (for upstream auth), and prints:

```
<guid>

# add to target's authorized_keys:
echo 'ssh-ed25519 AAAA...' >> ~/.ssh/authorized_keys

# connect with:
ssh <guid>@sshpiper -p 2222 # -> <username>@<host>:<port>

# press Ctrl+C to stop forwarding
```

The registrar installs the printed public key on the target host's
`authorized_keys`, and keeps the SSH session open — closing it tears
down the tunnel.

2. **Connect.** Anyone with the registrar's SSH key runs:
```
ssh <guid>@sshpiper
```
sshpiperd verifies the connector's public key matches the one used
during registration, then opens a `forwarded-tcpip` channel on the
registrar's connection. Inside that channel the daemon performs SSH
auth to `<host>:<port>` as `<username>` using the internal ed25519 key
(whose public half was installed in step 1).

## Usage

```
sshpiperd revtunnel [--session-store <spec>] [--host-key <path>] [--piper-host <host>] [--piper-port <port>]
```

Flags:

| flag | default | description |
| ---- | ------- | ----------- |
| `--session-store` (`SSHPIPERD_REVTUNNEL_SESSION_STORE`) | `memory://` | `memory://` (default, lost on restart) or `file://<dir>` (one JSON file per GUID, atomic). |
| `--host-key` (`SSHPIPERD_REVTUNNEL_HOST_KEY`) | _auto-generated ed25519_ | Path to an OpenSSH-format ed25519 private key for the embedded register-side SSH server. Auto-generated and persisted if the path does not exist; ephemeral if not set. |
| `--piper-host` (`SSHPIPERD_REVTUNNEL_PIPER_HOST`) | `sshpiper` | Hostname shown in the connect hint after registration. |
| `--piper-port` (`SSHPIPERD_REVTUNNEL_PIPER_PORT`) | `0` | Port shown in the connect hint; 0 or 22 omits the `-p` flag. |

## Behaviour & limits

- **Register-side auth is publickey.** The registrar must have an SSH key;
that key becomes the identity required for the connect side.
- **Connect-side auth is publickey only.** The username must be the GUID
and the key must match the one used during registration.
- **Idle timeout: 2 hours.** Records whose last byte of traffic (or
registration handshake) is older than 2h are evicted; the registrar's
SSH connection is dropped. Not configurable in this release.
- **Tunnel lifetime.** A tunnel disappears the moment the registrar's SSH
session ends; the GUID may still be stored on disk (with `file://`) but
new connect attempts are refused until the registrar re-registers.
- **Allocated bind port.** When the registrar uses `ssh -R 0:...`, the
plugin synthesises a pseudo-port for the RFC 4254 §7.1 reply (no real
socket is opened).
Loading
Loading