Skip to content
Open
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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,27 @@ MWB Linux implements the full Mouse Without Borders protocol:

For detailed protocol documentation, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).

## Security

Mouse Without Borders lets a remote machine inject keyboard and mouse input into
your Linux session, so treat it like remote control and run it only on networks
and machines you trust.

- **Trusted peer only.** The client accepts inbound connections **only from the
IP configured in `host`**; connections from any other source are rejected and
logged. No extra configuration is required — it reuses the `host` you already
set. (The outbound connection is unchanged.)
- **One shared secret.** All protection comes from the `key`. Use a long, random
key — anyone who obtains it and can reach the port can send input.
- **Firewall.** Only expose ports **15100–15101** to your trusted LAN. Block them
from the internet, and avoid using the client on untrusted/public Wi-Fi.
- **No sudo.** Run as your normal user (in the `input` group); `sudo mwb` can
attach to the wrong session and is unnecessary.

The transport uses the original MWB AES-256-CBC scheme, which is not
authenticated encryption — an attacker able to intercept LAN traffic could
tamper with or replay packets. Keep the client on a trusted network segment.

## Configuration

### config.toml
Expand Down
5 changes: 3 additions & 2 deletions cmd/mwb/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,10 @@ func main() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)

// Start TCP server to accept incoming connections from Windows MWB
// Start TCP server to accept incoming connections from Windows MWB.
// Only the configured host (cfg.Host) is accepted as an inbound peer.
serverStop := make(chan struct{})
incomingCh, err := network.ListenAndAccept(cfg.MessagePort(), cfg.Key, cfg.Name, serverStop)
incomingCh, err := network.ListenAndAccept(cfg.MessagePort(), cfg.Key, cfg.Name, cfg.Host, serverStop)
if err != nil {
fmt.Fprintf(os.Stderr, "error starting listener: %v\n", err)
fmt.Fprintln(os.Stderr, "Is another mwb instance already running?")
Expand Down
53 changes: 51 additions & 2 deletions internal/network/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,49 @@ func Connect(addr, securityKey, machineName string, timeout time.Duration) (*Con
return conn, nil
}

// isAllowedPeer reports whether addr's IP matches allowedHost. allowedHost may
// be an IP literal or a hostname (resolved via DNS to handle DHCP changes). An
// empty allowedHost disables the check. This lets the client accept inbound
// connections only from its configured peer instead of from any host that can
// reach the listening port.
func isAllowedPeer(addr net.Addr, allowedHost string) bool {
if allowedHost == "" {
return true
}
host, _, err := net.SplitHostPort(addr.String())
if err != nil {
host = addr.String()
}
ip := net.ParseIP(host)
if ip == nil {
return false
}
// allowedHost may already be an IP literal; ParseIP handles that without DNS.
if allowed := net.ParseIP(allowedHost); allowed != nil {
return allowed.Equal(ip)
}
allowedIPs, err := net.LookupHost(allowedHost)
if err != nil {
return false
}
for _, a := range allowedIPs {
if p := net.ParseIP(a); p != nil && p.Equal(ip) {
return true
}
}
return false
}

// ListenAndAccept starts a TCP server on the given port and sends accepted
// connections (after handshake) to the returned channel. This allows Windows
// MWB to connect TO us, which is faster after lock/reconnect cycles.
func ListenAndAccept(port int, securityKey, machineName string, stop chan struct{}) (chan *Conn, error) {
//
// allowedHost restricts which source may connect: only a peer whose IP matches
// the configured host (cfg.Host) is accepted. The client only ever talks to one
// peer, so accepting inbound connections from any other source on the network
// would let any device that guesses the key inject keyboard/mouse input. An
// empty allowedHost disables the check (accept from anyone).
func ListenAndAccept(port int, securityKey, machineName, allowedHost string, stop chan struct{}) (chan *Conn, error) {
connCh := make(chan *Conn, 1)
addr := fmt.Sprintf(":%d", port)
ln, err := net.Listen("tcp", addr)
Expand All @@ -148,7 +187,7 @@ func ListenAndAccept(port int, securityKey, machineName string, stop chan struct
go func() {
defer close(connCh)
defer func() { _ = ln.Close() }()
slog.Info("listening for incoming MWB connections", "port", port)
slog.Info("listening for incoming MWB connections", "port", port, "allowedHost", allowedHost)

for {
select {
Expand All @@ -171,6 +210,16 @@ func ListenAndAccept(port int, securityKey, machineName string, stop chan struct
continue
}

// Reject any source that is not the configured peer. This is the
// primary control that keeps an unknown host on the network from
// even reaching the key handshake.
if !isAllowedPeer(raw.RemoteAddr(), allowedHost) {
slog.Warn("rejected inbound connection from unexpected source",
"remote", raw.RemoteAddr(), "allowedHost", allowedHost)
_ = raw.Close()
continue
}

slog.Info("incoming connection", "remote", raw.RemoteAddr())
conn, err := setupConn(raw, securityKey, machineName)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/network/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ func TestListenAndAcceptReturnsBindError(t *testing.T) {

port := ln.Addr().(*net.TCPAddr).Port
stop := make(chan struct{})
connCh, err := ListenAndAccept(port, "TestSecurityKey!!", "linux-test", stop)
connCh, err := ListenAndAccept(port, "TestSecurityKey!!", "linux-test", "192.168.1.100", stop)
if err == nil {
close(stop)
t.Fatal("expected bind error, got nil")
Expand Down
36 changes: 36 additions & 0 deletions internal/network/peer_allowlist_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// internal/network/peer_allowlist_test.go
package network

import (
"net"
"testing"
)

func TestIsAllowedPeer(t *testing.T) {
tests := []struct {
name string
remote string // remote address "ip:port"
allowedHost string
want bool
}{
{"matching IP", "192.168.1.50:41000", "192.168.1.50", true},
{"different IP rejected", "192.168.1.99:41000", "192.168.1.50", false},
{"loopback not the peer rejected", "127.0.0.1:41000", "192.168.1.50", false},
{"empty allowedHost disables check", "10.0.0.7:41000", "", true},
{"IPv6 match", "[fe80::1]:41000", "fe80::1", true},
{"IPv6 mismatch", "[fe80::2]:41000", "fe80::1", false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
addr, err := net.ResolveTCPAddr("tcp", tt.remote)
if err != nil {
t.Fatalf("resolve %q: %v", tt.remote, err)
}
if got := isAllowedPeer(addr, tt.allowedHost); got != tt.want {
t.Errorf("isAllowedPeer(%q, %q) = %v, want %v",
tt.remote, tt.allowedHost, got, tt.want)
}
})
}
}