Skip to content
Merged
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
42 changes: 28 additions & 14 deletions contrib/kit-tunnel/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,16 +219,12 @@ fn parse_seed(hex_seed: &str) -> Vec<u8> {
/// Key material never travels in argv (world-readable via ps); the Go side
/// passes it in the child's environment and the mode flag selects the
/// variable to read.
fn secret_material(flags: &Flags, env_var: &str) -> String {
if let Some(flag) = ["secret-hex", "pair-seed-hex", "client-seed-hex"]
.iter()
.find_map(|f| {
let v = flags.get(f);
(!v.is_empty()).then_some(v)
})
{
// Direct hex flag (used by tests and manual runs).
return flag;
fn secret_material(flags: &Flags, flag_name: &str, env_var: &str) -> String {
// Only the calling mode's flag is honored: a stray sibling flag can
// never silently substitute the wrong key material.
let direct = flags.get(flag_name);
if !direct.is_empty() {
return direct;
}
std::env::var(env_var).unwrap_or_else(|_| fail(&format!("missing key material: set {env_var}")))
}
Expand Down Expand Up @@ -443,7 +439,10 @@ fn send_to_go(f: &Frame) -> bool {
}

async fn serve(flags: &Flags) {
let secret_bytes = parse_seed(&flags.get("secret-hex"));
let secret_bytes = parse_seed(&secret_material(flags, "secret-hex", "KIT_TUNNEL_SECRET"));
if secret_bytes.len() != 32 {
fail("daemon identity seed must be 32 bytes");
}
let secret = secret_from_seed(&secret_bytes);

let endpoint = Endpoint::builder(presets::N0)
Expand Down Expand Up @@ -775,7 +774,11 @@ async fn handle_connection(
// ---------------------------------------------------------------------------

async fn dial_pair(flags: &Flags) {
let seed = parse_seed(&secret_material(flags, "KIT_TUNNEL_PAIR_SEED"));
let seed = parse_seed(&secret_material(
flags,
"pair-seed-hex",
"KIT_TUNNEL_PAIR_SEED",
));
if seed.len() != 32 {
fail("pairing seed must be 32 bytes");
}
Expand Down Expand Up @@ -873,7 +876,11 @@ async fn dial_host(flags: &Flags) {
}
let server_id = EndpointId::from_bytes(&server_bytes.try_into().expect("checked above"))
.unwrap_or_else(|e| fail(&format!("bad endpoint id: {e}")));
let signing_seed = parse_seed(&secret_material(flags, "KIT_TUNNEL_CLIENT_SEED"));
let signing_seed = parse_seed(&secret_material(
flags,
"client-seed-hex",
"KIT_TUNNEL_CLIENT_SEED",
));
if signing_seed.len() != 32 {
fail("client seed must be 32 bytes");
}
Expand Down Expand Up @@ -1052,7 +1059,14 @@ async fn relay_client_session(mut send: SendStream, mut recv: RecvStream, sessio
/// id. The Go side enforces the window timeout; every wait here is also
/// bounded so a stalled peer cannot pin the task.
async fn serve_pair(flags: &Flags) {
let seed = parse_seed(&flags.get("pair-seed-hex"));
let seed = parse_seed(&secret_material(
flags,
"pair-seed-hex",
"KIT_TUNNEL_PAIR_SEED",
));
if seed.len() != 32 {
fail("pairing seed must be 32 bytes");
}
let key = Arc::new(auth_key(&seed));
let secret = secret_from_seed(&seed);

Expand Down
26 changes: 26 additions & 0 deletions internal/clipboard/clipboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,34 @@ package clipboard

import (
"fmt"
"os"
)

// RemoteClipboardEnv is the environment variable the daemon sets on
// remote-session child processes: it names a file holding the CLIENT
// machine's clipboard image. When set, ReadImage serves the image from
// that file instead of the local system clipboard — a remote session's
// Ctrl-V must attach the client's clipboard, not the daemon host's.
const RemoteClipboardEnv = "KIT_REMOTE_CLIPBOARD"

// ReadImage returns the image to attach for a Ctrl-V: when running as a
// remote-session child (RemoteClipboardEnv set) it reads the client's
// streamed clipboard image; otherwise it reads the local system clipboard.
func ReadImage() (*ImageData, error) {
if p := os.Getenv(RemoteClipboardEnv); p != "" {
data, err := os.ReadFile(p)
if err != nil || len(data) == 0 {
return nil, fmt.Errorf("no image on the remote clipboard")
}
mediaType := DetectMediaType(data)
if mediaType == "" {
return nil, fmt.Errorf("unrecognized remote clipboard content")
}
return &ImageData{Data: data, MediaType: mediaType}, nil
}
return readSystemImage()
}

// ImageData holds the result of a clipboard image read.
type ImageData struct {
// Data is the raw image bytes (PNG, JPEG, etc.).
Expand Down
2 changes: 1 addition & 1 deletion internal/clipboard/clipboard_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
// ReadImage reads image data from the system clipboard on macOS.
// It uses osascript to check if the clipboard contains an image via
// NSPasteboard and writes it to stdout as PNG data.
func ReadImage() (*ImageData, error) {
func readSystemImage() (*ImageData, error) {
// Use osascript to write clipboard image to stdout via a pipe.
// The script checks if the clipboard has a «class PNGf» item.
script := `use framework "AppKit"
Expand Down
4 changes: 3 additions & 1 deletion internal/clipboard/clipboard_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import (

// ReadImage reads image data from the system clipboard on Linux.
// It tries xclip first (X11), then falls back to wl-paste (Wayland).
func ReadImage() (*ImageData, error) {
// readSystemImage reads image data from the local system clipboard on
// Linux. It tries xclip first (X11), then falls back to wl-paste (Wayland).
func readSystemImage() (*ImageData, error) {
// Try xclip first (X11).
if path, err := exec.LookPath("xclip"); err == nil {
data, err := readWithXclip(path)
Expand Down
42 changes: 42 additions & 0 deletions internal/clipboard/clipboard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package clipboard

import (
"bytes"
"os"
"testing"
)

func TestReadImageRemoteClipboardEnv(t *testing.T) {
png := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 13}
dir := t.TempDir()
path := dir + "/clip"

// Remote clipboard file present: ReadImage serves it.
t.Setenv(RemoteClipboardEnv, path)
if err := os.WriteFile(path, png, 0o600); err != nil {
t.Fatal(err)
}
img, err := ReadImage()
if err != nil {
t.Fatalf("ReadImage: %v", err)
}
if !bytes.Equal(img.Data, png) || img.MediaType != "image/png" {
t.Fatalf("unexpected image: %d bytes %s", len(img.Data), img.MediaType)
}

// Empty file (the daemon's "no image" clear): must be a soft error.
if err := os.WriteFile(path, nil, 0o600); err != nil {
t.Fatal(err)
}
if _, err := ReadImage(); err == nil {
t.Fatal("empty remote clipboard should error")
}

// Unrecognized content: soft error, not a bogus attachment.
if err := os.WriteFile(path, []byte("not an image"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := ReadImage(); err == nil {
t.Fatal("unrecognized content should error")
}
}
2 changes: 1 addition & 1 deletion internal/clipboard/clipboard_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ package clipboard

// ReadImage reads image data from the system clipboard on Windows.
// Windows clipboard image support is not yet implemented.
func ReadImage() (*ImageData, error) {
func readSystemImage() (*ImageData, error) {
return nil, errNoClipboardTool
}
120 changes: 109 additions & 11 deletions internal/daemon/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"context"
"fmt"
"os"

"github.com/mark3labs/kit/internal/clipboard"
"strings"
"sync"
"sync/atomic"
Expand All @@ -18,6 +20,13 @@ import (
// forwarding the keystroke.
const detachKey = 0x1d

// pasteKey is Ctrl-V. In a remote session the client intercepts a bare
// Ctrl-V: it reads THIS machine's clipboard and streams any image to the
// daemon as FrameClipboard chunks (the host TUI would otherwise read the
// host's clipboard, which is the wrong one). When the clipboard holds no
// image the keystroke is forwarded verbatim.
const pasteKey = 0x16

// terminalResetSeq restores terminal modes the remote TUI may have enabled
// and we may not have seen disabled: alt screen off, cursor on, mouse and
// bracketed paste off, kitty keyboard protocol popped. Emitted by the
Expand Down Expand Up @@ -201,27 +210,116 @@ func RunHost(ctx context.Context, name string) error {
finish := func() { once.Do(func() { close(done) }) }

// Local keystrokes -> remote. A lone Ctrl-] detaches.
// Stdin reader: os.Stdin.Read blocks, so it feeds a channel and the
// pump below can also react to the Esc idle-flush timer.
readCh := make(chan []byte, 4)
readErr := make(chan error, 1)
go func() {
defer finish()
buf := make([]byte, 256)
defer close(readCh)
rbuf := make([]byte, 256)
for {
n, err := os.Stdin.Read(buf)
n, err := os.Stdin.Read(rbuf)
if n > 0 {
if n == 1 && buf[0] == detachKey {
out := make([]byte, n)
copy(out, rbuf[:n])
readCh <- out
}
if err != nil {
readErr <- err
return
}
}
}()

go func() {
defer finish()
scanner := &keyScanner{}
suppressRel := false // swallow the ctrl+v release after a successful image interception
var idleTimer *time.Timer
var idleC <-chan time.Time
armIdle := func() {
if scanner.PendingEscape() {
d := max(escIdleFlush-time.Since(scanner.escAt), 0)
if idleTimer == nil {
idleTimer = time.NewTimer(d)
} else {
idleTimer.Stop()
idleTimer.Reset(d)
}
idleC = idleTimer.C
} else if idleTimer != nil {
idleTimer.Stop()
idleC = nil
}
}
forward := func(data []byte) bool {
writeMu.Lock()
defer writeMu.Unlock()
return WriteDataFrames(tun.Stdin(), 0, data) == nil
}
handle := func(ev keyEvent) bool { // false = write error, give up
if ev.Paste {
// Image paste: read the local clipboard and stream any
// image to the daemon. No image — forward the keystroke
// so the host keeps its normal Ctrl-V behavior.
img, imgErr := clipboard.ReadImage()
if imgErr == nil && len(img.Data) > 0 {
writeMu.Lock()
sent := true
for _, payload := range EncodeClipboardChunks(img.MediaType, img.Data) {
if werr := WriteFrame(tun.Stdin(), FrameClipboard, 0, payload); werr != nil {
sent = false
break
}
}
writeMu.Unlock()
if !sent {
return false
}
suppressRel = true // the matching release is ours
fmt.Fprintln(os.Stderr, "Image sent from local clipboard.")
return true // swallow the press bytes
}
suppressRel = false // forwarding the press; forward its release too
}
if ev.Release && suppressRel {
suppressRel = false
return true
}
if len(ev.Data) > 0 {
return forward(ev.Data)
}
return true
}
armIdle()
for {
select {
case chunk, ok := <-readCh:
if !ok {
return
}
if len(chunk) == 1 && chunk[0] == detachKey {
writeMu.Lock()
_ = WriteFrame(tun.Stdin(), FrameBye, 0, nil)
writeMu.Unlock()
detached.Store(true)
return
}
writeMu.Lock()
werr := WriteDataFrames(tun.Stdin(), 0, buf[:n])
writeMu.Unlock()
if werr != nil {
return
for _, ev := range scanner.Feed(chunk) {
if !handle(ev) {
return
}
}
}
if err != nil {
armIdle()
case <-idleC:
idleC = nil
for _, ev := range scanner.FlushPendingEscape() {
if !handle(ev) {
return
}
}
armIdle()
case <-readErr:
return
}
}
Expand Down
Loading
Loading