Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
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-\S+ \S+) >> ~/\.ssh/authorized_keys$`)
Comment thread
tg123 marked this conversation as resolved.
Outdated
Comment thread
Copilot marked this conversation as resolved.
Outdated
)

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.
63 changes: 63 additions & 0 deletions examples/revtunnel/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# 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:

```
GUID=abc123-...
UPSTREAM_KEY=ssh-ed25519 AAAA...
```
Comment thread
tg123 marked this conversation as resolved.

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 `UPSTREAM_KEY` value and add it to the target's authorized_keys.
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'
```
Comment thread
tg123 marked this conversation as resolved.

## 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
77 changes: 77 additions & 0 deletions plugin/revtunnel/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# revtunnel plugin for sshpiperd

Register an ssh reverse tunnel with `ssh -R` and let anyone holding the
returned GUID + private key connect through sshpiperd back to a host that
is only reachable from the registrar.
Comment thread
tg123 marked this conversation as resolved.
Outdated

Comment thread
tg123 marked this conversation as resolved.
Outdated
## How it works

1. **Register.** The registrar runs:
```
ssh -R 0:<host>:<port> <username>@sshpiperd
```
`<host>:<port>` is reachable from the registrar (typically the
registrar's own sshd, e.g. `localhost:22`). `<username>` is the unix
account that connectors will land on at the target host.

The plugin terminates an embedded ssh server, accepts the
`tcpip-forward` global request, generates a fresh GUID and ed25519
keypair, and prints them to the registrar's session:

```
# revtunnel registration
GUID=<uuid>
BIND=<addr>:<port>
TARGET_USER=<username>
PUBLIC_KEY=ssh-ed25519 AAAA...
-----BEGIN REVTUNNEL PRIVATE KEY-----
-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----
-----END REVTUNNEL PRIVATE KEY-----
```
Comment thread
tg123 marked this conversation as resolved.
Outdated
Comment thread
tg123 marked this conversation as resolved.

The registrar copies `PUBLIC_KEY` into `~/.ssh/authorized_keys` on the
target host (`<username>@<host>:<port>`), and keeps the ssh session
open — closing it tears down the tunnel.

2. **Connect.** Anyone with the GUID + private key runs:
```
ssh -i id_revtunnel <guid>@sshpiperd
```
Comment thread
tg123 marked this conversation as resolved.
Outdated
sshpiperd verifies the pubkey against the stored one for that GUID,
then opens a `forwarded-tcpip` channel on the registrar's connection.
Inside that channel the daemon performs a regular ssh handshake to
`<host>:<port>` (the target the registrar selected) as `<username>`
using the same ed25519 key.

## Usage

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

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, in memory_ | Path to an OpenSSH-format ed25519 private key used as the host key of the embedded register-side ssh server. Auto-generated and persisted if the path does not exist. |

## Behaviour & limits

- **Register-side auth is `none`.** Any username works; access control is
enforced on the connect side via the issued ed25519 key.
- **Connect-side auth is publickey only.** The username must be the GUID.
- **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 + keys may still be stored on disk (with
`file://`) but new connect attempts are refused until the same registrar
re-registers. v1 does not support re-binding to an existing GUID — each
registration produces a fresh GUID.
- **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). The `BIND=` line in the registration output reflects
that allocated port.
Loading
Loading