Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
168 changes: 168 additions & 0 deletions e2e/revtunnel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
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 user@piper` to register a tunnel;
// read the GUID + private/public key block from the session output
// 3. publish the generated public key to host-publickey's authorized_keys
// 4. run `ssh -i <privkey> <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)
privPath := path.Join(keydir, "id_revtunnel")

// 1) Launch the registrar — interactive shell so our plugin can write
// the registration block to stdout. Keep the process alive for the
// entire test; the live ssh.Conn it holds is what the connect-side
// flow uses to open forwarded-tcpip channels.
registrar, regStdin, regStdout, err := runCmd(
"ssh",
"-tt",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "PreferredAuthentications=none",
"-o", "PubkeyAuthentication=no",
"-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, privPEM, pubAuthorized, err := readRegistration(regStdout, 15*time.Second)
if err != nil {
t.Fatalf("read registration: %v", err)
}
t.Logf("registered guid=%s pubkey=%s", guid, strings.TrimSpace(pubAuthorized))

if err := os.WriteFile(privPath, []byte(privPEM), 0o400); err != nil {
t.Fatalf("write privkey: %v", err)
}
if err := os.WriteFile(authorizedKeysPath, []byte(pubAuthorized+"\n"), 0o400); err != nil {
t.Fatalf("write authorized_keys: %v", err)
}

// 2) Connect through the tunnel and run a command on host-publickey.
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", privPath,
"-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, the OpenSSH-format public key, and the private key PEM block
// emitted by plugin/revtunnel. Because runCmd returns a *bytes.Buffer that
// reports io.EOF whenever it's drained, we re-scan from the start of buf
// each iteration (matching waitForStdoutContains's pattern) and slow-poll
// until the full block is present or we hit the timeout.
var (
reGUID = regexp.MustCompile(`GUID=([0-9a-fA-F-]{8,})`)
rePub = regexp.MustCompile(`PUBLIC_KEY=(.+)`)
beginPriv = "-----BEGIN REVTUNNEL PRIVATE KEY-----"
endPriv = "-----END REVTUNNEL PRIVATE KEY-----"
)

func readRegistration(r io.Reader, timeout time.Duration) (guid, privPEM, pubAuthorized 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, privPEM, pubAuthorized, ok := parseRegistration(buf.Bytes())
if ok {
return guid, privPEM, pubAuthorized, nil
}
if time.Now().After(deadline) {
return "", "", "", fmt.Errorf("timed out after %s; partial data: guid=%q pub=%q priv-bytes=%d", timeout, guid, pubAuthorized, len(privPEM))
}
time.Sleep(250 * time.Millisecond)
}
}

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

var privBuf bytes.Buffer
inPriv := false
gotEnd := false
for scanner.Scan() {
line := strings.TrimRight(scanner.Text(), "\r")
switch {
case inPriv:
if line == endPriv {
inPriv = false
gotEnd = true
continue
}
privBuf.WriteString(line)
privBuf.WriteByte('\n')
case line == beginPriv:
inPriv = true
privBuf.Reset()
gotEnd = false
case reGUID.MatchString(line):
guid = reGUID.FindStringSubmatch(line)[1]
case rePub.MatchString(line):
pubAuthorized = strings.TrimSpace(rePub.FindStringSubmatch(line)[1])
}
}
return guid, privBuf.String(), pubAuthorized, guid != "" && pubAuthorized != "" && gotEnd
}
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