-
-
Notifications
You must be signed in to change notification settings - Fork 163
feat: add revtunnel reverse-tunnel plugin #802
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
tg123
wants to merge
10
commits into
master
Choose a base branch
from
feat/reverse-tunnel-plugin
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
bc2bc91
feat: add revtunnel reverse-tunnel plugin
tg123 e358fee
Merge branch 'master' into feat/reverse-tunnel-plugin
tg123 7c9c49c
feat: implement revtunnel plugin with registration and connection han…
tg123 d5a35d2
Merge branch 'feat/reverse-tunnel-plugin' of https://github.com/tg123…
tg123 dccc12c
feat: enhance revtunnel plugin with improved host key handling and sa…
tg123 a7d6aae
feat: update regex for upstream public key handling in revtunnel tests
tg123 ea4f181
feat: update regex for upstream public key handling in revtunnel tests
tg123 2859631
fix: address PR review - throttle persistence, update README, fix regex
tg123 2a5d775
fix: address review - close guidCh after wg.Wait, dial-before-enqueue…
tg123 2333a83
fix: address review - pendingKeys race, guidCh hang, Touch throttle
tg123 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
|
|
||
|
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----- | ||
| ``` | ||
|
tg123 marked this conversation as resolved.
Outdated
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 | ||
| ``` | ||
|
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. | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.