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
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,10 +300,24 @@ desktop. Clipboard watching is macOS-only; other platforms can still serve
explicit pushes and pull.

```bash
mole clip serve # on the source machine (binds clip_listen, default 0.0.0.0:7777)
mole clip serve # on the source machine (default: loopback 127.0.0.1:7777)
mole clip pull # on the target; uses clip_url from the config, or -url
```

The clip endpoint has no authentication. The default loopback bind keeps it
off the network; for a remote pull, bind to the source machine's private
WireGuard or Tailscale address explicitly and use the same address in
`clip_url`:

```yaml
clip_url: http://100.64.0.10:7777
clip_listen: 100.64.0.10:7777
```

Binding to `0.0.0.0:7777`, `:7777`, or `[::]:7777` is supported for controlled
networks, but `mole clip serve` emits a warning because every interface can
reach the unauthenticated endpoint.


### Generate the config with `mole init`

Expand Down Expand Up @@ -363,7 +377,7 @@ mole ports remove|rm <port> [-config PATH]
mole ports list|ls [-config PATH]
mole config edit [-config PATH] [-editor CMD]
mole init [flags]
mole clip serve [-listen 0.0.0.0:7777] [-watch] [-config PATH] [-log-level L]
mole clip serve [-listen 127.0.0.1:7777] [-watch] [-config PATH] [-log-level L]
mole clip pull [-url URL] [-config PATH] [-log-level L]
mole update [-version REF] [-dry-run] [-no-verify]
mole version · mole help
Expand All @@ -383,7 +397,7 @@ mole version · mole help
| `ssh_port` | int | `22` | SSH port on the remote |
| `insecure` | bool | `false` | Disable SSH host key verification (UNSAFE; dev only) |
| `clip_url` | string | — | Clip server URL used by `mole clip pull` |
| `clip_listen` | string | `0.0.0.0:7777` | Bind address for `mole clip serve` |
| `clip_listen` | string | `127.0.0.1:7777` | Bind address; use a private Tailscale/WireGuard IP for remote access |
| `clip_interval_ms` | int | — | Clipboard poll interval for `clip serve -watch` |

Fallback `discover_ports`:
Expand Down
32 changes: 25 additions & 7 deletions cmd/mole/clip.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// mole clip — share clipboard images between a Mac and a remote
// host (typically an LXC) over a WireGuard link.
// host (typically an LXC) over a private WireGuard or Tailscale link.
//
// Two sub-modes:
//
Expand Down Expand Up @@ -58,7 +58,7 @@ func runClip(args []string) int {

func printClipUsage(w *os.File) {
color := cliColor(w)
fmt.Fprintf(w, "%s\n\n", cBold("mole clip — share clipboard images over a WireGuard link", color))
fmt.Fprintf(w, "%s\n\n", cBold("mole clip — share clipboard images over a private WireGuard or Tailscale link", color))
fmt.Fprintf(w, " %s\n", cBold("USAGE", color))
fmt.Fprintf(w, " mole clip %s\n", cDim("<serve|pull> [flags]", color))
fmt.Println()
Expand All @@ -67,8 +67,8 @@ func printClipUsage(w *os.File) {
fmt.Fprintf(w, " %s %s\n", cGreen("pull", color), "Run on the remote; fetch the latest image and print its path.")
fmt.Println()
fmt.Fprintf(w, " %s\n", cBold("NOTES", color))
fmt.Fprintf(w, " %s\n", cDim(" serve binds -listen (default 0.0.0.0:7777). On a WireGuard-only host,", color))
fmt.Fprintf(w, " %s\n", cDim(" the link's perimeter is the security boundary. There is no auth.", color))
fmt.Fprintf(w, " %s\n", cDim(" serve binds -listen (default "+config.DefaultClipListen+"). The endpoint has no auth;", color))
fmt.Fprintf(w, " %s\n", cDim(" use a private Tailscale/WireGuard address when remote access is required.", color))
fmt.Fprintf(w, " %s\n", cDim(" pull prints the image path on stdout and exits. Exit code 3 means", color))
fmt.Fprintf(w, " %s\n", cDim(" 'no image on the server yet'.", color))
}
Expand All @@ -80,7 +80,7 @@ func runClipServe(args []string) int {
fs := flag.NewFlagSet("clip serve", flag.ExitOnError)
var (
configPath = fs.String("config", "", "path to YAML config (default: ./mole.yaml, then user-global)")
listen = fs.String("listen", "0.0.0.0:7777", "address to bind the clip HTTP server")
listen = fs.String("listen", config.DefaultClipListen, "address to bind the clip HTTP server")
watch = fs.Bool("watch", true, "poll the macOS clipboard and auto-push new images")
interval = fs.Duration("interval", 500*time.Millisecond, "clipboard poll cadence (only used with -watch)")
logLevel = fs.String("log-level", "", "debug|info|warn|error")
Expand All @@ -93,7 +93,7 @@ polls the macOS pasteboard to keep the latest image cached.

Flags:
-config path to YAML config (default: ./mole.yaml, then user-global)
-listen bind address (default 0.0.0.0:7777)
-listen bind address (default 127.0.0.1:7777)
-watch poll the macOS clipboard and push new images (default true)
-interval clipboard poll cadence (default 500ms)
-log-level debug|info|warn|error`)
Expand Down Expand Up @@ -123,13 +123,16 @@ Flags:
*logLevel = cfg.LogLevel
}
log := newLogger(*logLevel)

ln, err := net.Listen("tcp", *listen)
if err != nil {
log.Error("clip serve: listen failed", "addr", *listen, "err", err)
return 1
}
defer ln.Close()
effectiveAddr := ln.Addr().String()
if isBroadClipBind(effectiveAddr) {
log.Warn("clip server has no authentication and is listening on all interfaces", "addr", effectiveAddr, "hint", "use loopback or a private Tailscale/WireGuard address")
}

srv := &http.Server{
Handler: clip.New(log).Handler(),
Expand Down Expand Up @@ -171,6 +174,21 @@ Flags:
return 0
}

func isBroadClipBind(addr string) bool {
if addr == "" {
return true
}
host, _, err := net.SplitHostPort(addr)
if err != nil {
return false
}
if host == "" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsUnspecified()
}

// runClipPull fetches the latest image from the clip server and
// prints its path to stdout. Exits 0 on success, 3 on
// ErrNoImage, 1 on any other error.
Expand Down
18 changes: 18 additions & 0 deletions cmd/mole/clip_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,24 @@ func TestClipPull_NoImage_ExitCodeMapping(t *testing.T) {
}
}

func TestClipBindScope(t *testing.T) {
cases := map[string]bool{
"127.0.0.1:7777": false,
"100.64.0.10:7777": false,
"10.0.0.10:7777": false,
"[::1]:7777": false,
"0.0.0.0:7777": true,
":7777": true,
"[::]:7777": true,
"": true,
}
for addr, wantBroad := range cases {
if got := isBroadClipBind(addr); got != wantBroad {
t.Errorf("isBroadClipBind(%q) = %v, want %v", addr, got, wantBroad)
}
}
}

// silentLogger returns a logger that throws away every record. Keeps
// the test output clean.
func silentLogger() *slog.Logger {
Expand Down
80 changes: 67 additions & 13 deletions cmd/mole/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"fmt"
"io"
"net"
"net/url"
"os"
"os/exec"
"path/filepath"
Expand All @@ -38,8 +39,8 @@ type initAnswers struct {
PrintOnly bool // true → write to stdout instead of a file
Global bool // true → ~/.config/mole/config.yaml
ClipEnabled bool // true → also write clip_* keys for `mole clip`
ClipURL string // URL the LXC pulls from; e.g. http://10.0.0.1:7777
ClipListen string // address the Mac serves on; defaults to 0.0.0.0:7777
ClipURL string // URL the LXC pulls from; e.g. http://100.64.0.10:7777
ClipListen string // address the Mac serves on; defaults to 127.0.0.1:7777
ClipIntervalMs int // clipboard poll cadence on the Mac
}

Expand All @@ -59,7 +60,7 @@ func runInit(args []string) int {
force = fs.Bool("force", false, "overwrite the config file if it already exists")
clip = fs.Bool("clip", false, "configure clip_* keys so `mole clip` works (skip the interactive prompt)")
clipURL = fs.String("clip-url", "", "URL the LXC pulls from (only used with -clip); e.g. http://10.0.0.1:7777")
clipListen = fs.String("clip-listen", "0.0.0.0:7777", "address the Mac serves on (only used with -clip)")
clipListen = fs.String("clip-listen", "", "address the Mac serves on (only used with -clip; defaults to the clip URL or 127.0.0.1:7777)")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
clipInterval = fs.Int("clip-interval-ms", 500, "clipboard poll cadence in ms (only used with -clip)")
)
fs.Usage = func() {
Expand All @@ -80,11 +81,16 @@ Flags:
-test after writing, test the SSH connection
-force overwrite the config file if it already exists
-up start mole (mole up) immediately after writing
-clip configure clipboard sharing options
-clip-url <url> URL the remote LXC pulls clipboard images from
-clip-listen <addr> address the local clipboard server binds on
-clip-interval-ms <n> clipboard polling interval in milliseconds
-h, --help show this help

Environment (read when the corresponding flag is empty):
MOLE_REMOTE, MOLE_PORTS, MOLE_AUTO_DISCOVER,
MOLE_CONFIG_PATH, MOLE_GLOBAL`)
MOLE_CONFIG_PATH, MOLE_GLOBAL, MOLE_CLIP,
MOLE_CLIP_URL, MOLE_CLIP_LISTEN`)
}
if err := fs.Parse(args); err != nil {
return 2
Expand Down Expand Up @@ -244,7 +250,12 @@ func gatherAnswers(in initInputs, opt initOptions) (*initAnswers, error) {
in.ClipURL = envDefault("MOLE_CLIP_URL", "")
}
if in.ClipListen == "" {
in.ClipListen = envDefault("MOLE_CLIP_LISTEN", "0.0.0.0:7777")
in.ClipListen = envDefault("MOLE_CLIP_LISTEN", "")
}
in.ClipListen = strings.TrimSpace(in.ClipListen)
clipListenProvided := in.ClipListen != ""
if !clipListenProvided {
in.ClipListen = config.DefaultClipListen
}

ans := &initAnswers{
Expand All @@ -259,6 +270,15 @@ func gatherAnswers(in initInputs, opt initOptions) (*initAnswers, error) {
ClipIntervalMs: in.ClipIntervalMs,
}
ans.Ports = config.ParsePorts(in.PortsCSV)
if ans.ClipEnabled && ans.ClipURL != "" {
listen, err := clipListenForURL(ans.ClipURL)
if err != nil {
return nil, err
}
if !clipListenProvided {
ans.ClipListen = listen
}
}

// In non-interactive mode, all required values must already be set.
if !opt.Interactive {
Expand Down Expand Up @@ -341,24 +361,33 @@ func gatherAnswers(in initInputs, opt initOptions) (*initAnswers, error) {
default:
ans.AutoDiscover = false
ans.Ports = nil
}

// Clipboard sharing: ask once whether to wire it up. Default is
// "no" so an unsuspecting user doesn't open a port they don't
// need. We only ask in interactive mode; -clip / -clip-url on
// the command line skip the question entirely.
if !ans.ClipEnabled && in.ClipURL == "" {
raw := prompt(opt.In, opt.Out, "Sync clipboard screenshots over WireGuard? [y/N]", "n")
raw := prompt(opt.In, opt.Out, "Sync clipboard screenshots over Tailscale/WireGuard? [y/N]", "n")
ans.ClipEnabled = strings.HasPrefix(strings.ToLower(strings.TrimSpace(raw)), "y")
if ans.ClipEnabled {
// Ask for the URL the LXC will pull from. We default to
// 10.0.0.1:7777 (a common WireGuard /24) but the user
// almost certainly needs to override this with their
// actual wg IP.
urlRaw := prompt(opt.In, opt.Out, "Mac WireGuard IP the LXC will pull from (e.g. 10.0.0.1:7777)", "10.0.0.1:7777")
// 100.64.0.10:7777 (a representative Tailscale address),
// but the user almost certainly needs to override this with
// their actual Tailscale or WireGuard IP.
urlRaw := prompt(opt.In, opt.Out, "Mac private IP the LXC will pull from (e.g. 100.64.0.10:7777)", "100.64.0.10:7777")
if !strings.HasPrefix(urlRaw, "http://") && !strings.HasPrefix(urlRaw, "https://") {
urlRaw = "http://" + urlRaw
}
ans.ClipURL = urlRaw
ans.ClipListen = prompt(opt.In, opt.Out, "Address mole clip serve binds on the Mac", "0.0.0.0:7777")
listenDefault, err := clipListenForURL(ans.ClipURL)
if err != nil {
return nil, err
}
if clipListenProvided {
listenDefault = ans.ClipListen
}
ans.ClipListen = prompt(opt.In, opt.Out, "Address mole clip serve binds on the Mac (use a private Tailscale/WireGuard IP for remote access)", listenDefault)
intervalRaw := prompt(opt.In, opt.Out, "Clipboard poll interval (ms)", "500")
if n, perr := strconv.Atoi(strings.TrimSpace(intervalRaw)); perr == nil && n > 0 {
ans.ClipIntervalMs = n
Expand All @@ -367,7 +396,6 @@ func gatherAnswers(in initInputs, opt initOptions) (*initAnswers, error) {
}
}
}
}

// Save location. If the user already pinned a path via -config /
// -global / env var, respect that and don't ask.
Expand Down Expand Up @@ -434,7 +462,7 @@ func renderYAML(ans *initAnswers) string {
}
}
if ans.ClipEnabled {
b.WriteString("\n# Clipboard sharing over a WireGuard link. `mole clip serve`\n")
b.WriteString("\n# Clipboard sharing over a private WireGuard or Tailscale link. `mole clip serve`\n")
b.WriteString("# on the Mac binds clip_listen; `mole clip pull` on the remote\n")
b.WriteString("# reaches clip_url. Both commands auto-read these keys.\n")
b.WriteString("clip_url: ")
Expand All @@ -456,6 +484,32 @@ func renderYAML(ans *initAnswers) string {
// Helpers
// ---------------------------------------------------------------------------

func clipListenForURL(raw string) (string, error) {
endpoint := strings.TrimSpace(raw)
if endpoint == "" {
return config.DefaultClipListen, nil
}
if !strings.Contains(endpoint, "://") {
endpoint = "http://" + endpoint
}
parsed, err := url.Parse(endpoint)
if err != nil {
return "", errors.New("invalid clip URL: malformed syntax or port")
}
if parsed.Hostname() == "" {
return "", errors.New("invalid clip URL: missing host")
}
port := parsed.Port()
if port == "" {
port = "7777"
}
portNumber, err := strconv.Atoi(port)
if err != nil || portNumber < 1 || portNumber > 65535 {
return "", errors.New("invalid clip URL: port must be between 1 and 65535")
}
return net.JoinHostPort(parsed.Hostname(), port), nil
}

func validateRemote(r string) error {
r = strings.TrimSpace(r)
if r == "" {
Expand Down
Loading
Loading