diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0851d5d..d74b5e4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -182,6 +182,7 @@ jobs: mapfile -t FILES < <(find artifacts -type f \( \ -name 'EntangledVPN-*-x86_64.AppImage' -o \ -name 'Entangled.exe' -o \ + -name 'Entangled.exe.sha256' -o \ -name 'EntangledVPN-Setup-*.exe' -o \ -name 'entangled-server-linux-amd64' -o \ -name 'entangled-server-windows-amd64.exe' \ @@ -191,5 +192,11 @@ jobs: find artifacts -type f exit 1 fi + # Publish SHA-256 for Windows Wails in-app update integrity checks. + EXE="$(find artifacts -type f -name 'Entangled.exe' | head -n1 || true)" + if [[ -n "$EXE" ]]; then + sha256sum "$EXE" | awk '{print $1" Entangled.exe"}' > artifacts/Entangled.exe.sha256 + FILES+=("artifacts/Entangled.exe.sha256") + fi gh release upload "$TAG" "${FILES[@]}" --clobber gh release view "$TAG" --json assets --jq '.assets[].name' diff --git a/CHANGELOG.md b/CHANGELOG.md index 4052b8e..6eb9cb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,27 @@ ## Unreleased -### Added +## 1.4.1 — 2026-09-17 + +### Security + +- Windows in-app update requires `Entangled.exe.sha256` and verifies SHA-256 before swap +- Electron: Chromium sandbox enabled; Linux installer no longer passes `--no-sandbox` +- Electron Linux sidecar: SHA-256 integrity check before `setcap` +- Server setup generates/requires `ENTANGLED_TOKEN` by default (`ENTANGLED_ALLOW_OPEN_JOIN=1` for explicit open join) +- Rooms persist owner token hashes only; empty-token owner reclaim refused +- Relay REG binds to first UDP address for the token; WS read limit + relay_data size cap +- Client logs use mode `0600`; invite strings omit room passwords + +### Fixed -- GitHub Actions **Release** workflow: builds `Entangled.exe` (Wails), Windows Electron NSIS installer, Linux AppImage, and server binaries -- Windows Electron NSIS setup (`EntangledVPN-Setup-*-x64.exe`) with `requireAdministrator` +- Leave/rejoin UDP hang (`PeerManager.Stop` closes conn; listener stopped on leave) +- Create/Join persist only after `room_joined` +- Connect waits for auth before ready; reconnect auth abort is non-intentional +- `https://` no longer silently downgrades to cleartext `ws://` on non-443 +- Critical signaling no longer silently dropped on full send buffers +- TUN setup runs off the WebSocket read path +- Atomic config/rooms writes; Windows Run key path quoted; P2P match by full UDP address ## 1.4.0 — 2026-09-10 diff --git a/SECURITY.md b/SECURITY.md index 6316248..a0a00d7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,6 +8,8 @@ Open a private advisory or email the maintainer if you find a vulnerability. Do See the short threat model in [README.md](README.md). Summary: peer traffic is encrypted end-to-end with HKDF-derived keys; the signaling/relay operator can still see metadata and MITM key exchange if you do not trust them. Prefer a self-hosted server among friends and `wss://` in front of the WebSocket. +Public installs should set `ENTANGLED_TOKEN` (the setup script generates one by default). Empty-token open join is only for private VPS use behind `ENTANGLED_ALLOW_OPEN_JOIN=1`. + ## Secrets in git history Older commits in the pre-OSS local history contained deploy tooling (host, SSH key material, key passphrase). That history must not be pushed publicly. The published `master` is a clean tree without those blobs. If you ever cloned an unclean history, rotate affected VPS SSH keys and credentials immediately. diff --git a/client/app.go b/client/app.go index 3910235..b7f772c 100644 --- a/client/app.go +++ b/client/app.go @@ -199,11 +199,42 @@ func (a *App) writeRooms(rooms []SavedRoomEntry) { vpncore.Logger.Printf("SaveRoom: marshal error: %v", err) return } - if err := os.WriteFile(roomsPath(), data, 0600); err != nil { + if err := writeFileAtomic(roomsPath(), data, 0600); err != nil { vpncore.Logger.Printf("SaveRoom: write error: %v", err) } } +func writeFileAtomic(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".entangled-tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + ok := false + defer func() { + if !ok { + _ = os.Remove(tmpName) + } + }() + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Chmod(perm); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpName, path); err != nil { + return err + } + ok = true + return nil +} + func (a *App) RemoveSavedRoom(name string) { rooms := a.loadRoomsRaw() filtered := make([]SavedRoomEntry, 0, len(rooms)) @@ -266,10 +297,13 @@ func (a *App) GetVersion() string { } func (a *App) GetStatus() AppStatus { - if a.vpn == nil { + a.mu.Lock() + vpn := a.vpn + a.mu.Unlock() + if vpn == nil { return AppStatus{} } - s := a.vpn.GetStatus() + s := vpn.GetStatus() return statusFrom(s) } @@ -335,7 +369,7 @@ func (a *App) SaveConfig(cfg ClientConfig) { vpncore.Logger.Printf("SaveConfig: marshal error: %v", err) return } - if err := os.WriteFile(configPath(), data, 0600); err != nil { + if err := writeFileAtomic(configPath(), data, 0600); err != nil { vpncore.Logger.Printf("SaveConfig: write error: %v", err) } } @@ -393,7 +427,7 @@ func (a *App) SetStartWithWindows(enabled bool) { "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "EntangledVPN", "/t", "REG_SZ", - "/d", exePath, + "/d", `"`+exePath+`"`, "/f", ) if out, err := cmd.CombinedOutput(); err != nil { @@ -557,6 +591,14 @@ func (a *App) wireVPN(vpn *vpncore.VPNCore) { vpn.OnOwnerToken = func(room, token string) { a.saveOwnerToken(room, token) } + vpn.OnRoomJoined = func(room, password string) { + a.lastRoomName = room + if password != "" { + a.lastRoomPass = password + } + a.SaveRoom(room, a.lastRoomPass) + a.persistLastRoom(room, a.lastRoomPass != "") + } } func (a *App) Disconnect() { @@ -582,14 +624,10 @@ func (a *App) CreateRoom(name, password string) error { if vpn == nil { return fmt.Errorf("not connected") } - if err := vpn.CreateRoom(name, password); err != nil { - return err - } a.lastRoomName = name a.lastRoomPass = password - a.SaveRoom(name, password) - a.persistLastRoom(name, password != "") - return nil + // Persist only after server room_joined (OnRoomJoined). + return vpn.CreateRoom(name, password) } func (a *App) JoinRoom(name, password string) error { @@ -602,14 +640,9 @@ func (a *App) JoinRoom(name, password string) error { if vpn == nil { return fmt.Errorf("not connected") } - if err := vpn.JoinRoom(name, password); err != nil { - return err - } a.lastRoomName = name a.lastRoomPass = password - a.SaveRoom(name, password) - a.persistLastRoom(name, password != "") - return nil + return vpn.JoinRoom(name, password) } func (a *App) LeaveRoom() { @@ -638,10 +671,13 @@ func (a *App) DeleteRoom(name string) error { } func (a *App) GetPeers() []PeerInfo { - if a.vpn == nil { + a.mu.Lock() + vpn := a.vpn + a.mu.Unlock() + if vpn == nil { return nil } - peers := a.vpn.GetPeers() + peers := vpn.GetPeers() info := make([]PeerInfo, len(peers)) for i, p := range peers { id, nick, vip, conn, ping, path := p.Snapshot() @@ -659,29 +695,32 @@ func (a *App) GetPeers() []PeerInfo { func (a *App) SendChat(toID, message string) error { a.mu.Lock() - defer a.mu.Unlock() - if a.vpn == nil { + vpn := a.vpn + a.mu.Unlock() + if vpn == nil { return fmt.Errorf("not connected") } - return a.vpn.SendChat(toID, message) + return vpn.SendChat(toID, message) } func (a *App) BroadcastChat(message string) error { a.mu.Lock() - defer a.mu.Unlock() - if a.vpn == nil { + vpn := a.vpn + a.mu.Unlock() + if vpn == nil { return fmt.Errorf("not connected") } - return a.vpn.BroadcastChat(message) + return vpn.BroadcastChat(message) } func (a *App) PingPeer(peerID string) error { a.mu.Lock() - defer a.mu.Unlock() - if a.vpn == nil { + vpn := a.vpn + a.mu.Unlock() + if vpn == nil { return fmt.Errorf("not connected") } - return a.vpn.PingPeer(peerID) + return vpn.PingPeer(peerID) } func (a *App) CopyText(text string) { @@ -690,13 +729,11 @@ func (a *App) CopyText(text string) { } } -// FormatInvite returns server|room|password for sharing. +// FormatInvite returns server|room (password omitted — share out-of-band). func (a *App) FormatInvite(room, password string) string { cfg := a.LoadConfig() - if password == "" && room == a.lastRoomName { - password = a.lastRoomPass - } - return cfg.ServerAddr + "|" + room + "|" + password + _ = password + return cfg.ServerAddr + "|" + room } // ParseInvite parses server|room|password (password may be empty). diff --git a/client/update.go b/client/update.go index c63afaf..0e1ff64 100644 --- a/client/update.go +++ b/client/update.go @@ -1,6 +1,8 @@ package main import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" @@ -19,6 +21,7 @@ import ( const ( updateRepoAPI = "https://api.github.com/repos/Warexpor/EntangledVPN/releases/latest" updateAssetName = "Entangled.exe" + updateSHAName = "Entangled.exe.sha256" updateUserAgent = "EntangledVPN/" + vpncore.AppVersion ) @@ -29,6 +32,8 @@ type UpdateInfo struct { Available bool `json:"available"` Notes string `json:"notes"` AssetURL string `json:"assetURL"` + SHA256 string `json:"sha256"` + shaURL string } type ghRelease struct { @@ -59,7 +64,10 @@ func (a *App) ApplyUpdate() error { if !info.Available || info.AssetURL == "" { return fmt.Errorf("no update available") } - if err := applyUpdateFromURL(info.AssetURL); err != nil { + if info.SHA256 == "" { + return fmt.Errorf("update rejected: missing release SHA-256") + } + if err := applyUpdateFromURL(info.AssetURL, info.SHA256); err != nil { return err } // Quit so the swap script can replace the locked exe. @@ -96,7 +104,16 @@ func fetchLatestUpdate() (UpdateInfo, error) { if err := json.Unmarshal(body, &rel); err != nil { return out, err } - return parseRelease(current, rel) + info, err := parseRelease(current, rel) + if err != nil { + return out, err + } + digest, err := fetchSHA256Digest(info.shaURL) + if err != nil { + return out, err + } + info.SHA256 = digest + return info, nil } func parseRelease(current string, rel ghRelease) (UpdateInfo, error) { @@ -109,19 +126,29 @@ func parseRelease(current string, rel ghRelease) (UpdateInfo, error) { out.Notes = truncateNotes(rel.Body, 2000) assetURL := "" + shaURL := "" for _, a := range rel.Assets { - if a.Name == updateAssetName { + switch a.Name { + case updateAssetName: assetURL = a.BrowserDownloadURL - break + case updateSHAName: + shaURL = a.BrowserDownloadURL } } if assetURL == "" { return out, fmt.Errorf("%s not found in latest release", updateAssetName) } + if shaURL == "" { + return out, fmt.Errorf("%s not found in latest release (required for integrity)", updateSHAName) + } if err := validateDownloadURL(assetURL); err != nil { return out, err } + if err := validateDownloadURL(shaURL); err != nil { + return out, err + } out.AssetURL = assetURL + out.shaURL = shaURL cmp, err := compareSemver(latest, current) if err != nil { @@ -131,6 +158,42 @@ func parseRelease(current string, rel ghRelease) (UpdateInfo, error) { return out, nil } +func fetchSHA256Digest(shaURL string) (string, error) { + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequest(http.MethodGet, shaURL, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", updateUserAgent) + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("sha256 download: HTTP %d", resp.StatusCode) + } + if err := validateDownloadURL(resp.Request.URL.String()); err != nil { + return "", err + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if err != nil { + return "", err + } + fields := strings.Fields(string(body)) + if len(fields) == 0 { + return "", fmt.Errorf("empty sha256 file") + } + digest := strings.ToLower(fields[0]) + if len(digest) != 64 { + return "", fmt.Errorf("invalid sha256 digest") + } + if _, err := hex.DecodeString(digest); err != nil { + return "", fmt.Errorf("invalid sha256 digest") + } + return digest, nil +} + func truncateNotes(s string, max int) string { s = strings.TrimSpace(s) if max <= 0 || len(s) <= max { @@ -200,10 +263,14 @@ func parseSemver(v string) ([3]int, error) { return out, nil } -func applyUpdateFromURL(assetURL string) error { +func applyUpdateFromURL(assetURL, wantSHA256 string) error { if err := validateDownloadURL(assetURL); err != nil { return err } + wantSHA256 = strings.ToLower(strings.TrimSpace(wantSHA256)) + if len(wantSHA256) != 64 { + return fmt.Errorf("invalid expected sha256") + } exe, err := os.Executable() if err != nil { return err @@ -237,7 +304,8 @@ func applyUpdateFromURL(assetURL string) error { if err != nil { return err } - _, copyErr := io.Copy(f, io.LimitReader(resp.Body, 200<<20)) // 200 MiB cap + h := sha256.New() + _, copyErr := io.Copy(f, io.TeeReader(io.LimitReader(resp.Body, 200<<20), h)) closeErr := f.Close() if copyErr != nil { os.Remove(newPath) @@ -247,6 +315,11 @@ func applyUpdateFromURL(assetURL string) error { os.Remove(newPath) return closeErr } + got := hex.EncodeToString(h.Sum(nil)) + if got != wantSHA256 { + os.Remove(newPath) + return fmt.Errorf("update hash mismatch") + } script := filepath.Join(os.TempDir(), "entangled-update.cmd") content := "" + diff --git a/client/update_test.go b/client/update_test.go index 9c8a53e..75bd6c2 100644 --- a/client/update_test.go +++ b/client/update_test.go @@ -37,13 +37,14 @@ func TestParseRelease(t *testing.T) { }{ {Name: "entangled-server-linux-amd64", BrowserDownloadURL: "https://github.com/Warexpor/EntangledVPN/releases/download/v1.2.0/server"}, {Name: "Entangled.exe", BrowserDownloadURL: "https://github.com/Warexpor/EntangledVPN/releases/download/v1.2.0/Entangled.exe"}, + {Name: "Entangled.exe.sha256", BrowserDownloadURL: "https://github.com/Warexpor/EntangledVPN/releases/download/v1.2.0/Entangled.exe.sha256"}, }, } info, err := parseRelease("1.1.0", rel) if err != nil { t.Fatal(err) } - if !info.Available || info.Latest != "1.2.0" || info.AssetURL == "" { + if !info.Available || info.Latest != "1.2.0" || info.AssetURL == "" || info.shaURL == "" { t.Fatalf("unexpected: %+v", info) } @@ -56,6 +57,22 @@ func TestParseRelease(t *testing.T) { } } +func TestParseReleaseMissingSHA(t *testing.T) { + rel := ghRelease{ + TagName: "v9.0.0", + Assets: []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + }{ + {Name: "Entangled.exe", BrowserDownloadURL: "https://github.com/Warexpor/EntangledVPN/releases/download/v9.0.0/Entangled.exe"}, + }, + } + _, err := parseRelease("1.0.0", rel) + if err == nil { + t.Fatal("expected missing sha256 asset error") + } +} + func TestParseReleaseMissingAsset(t *testing.T) { rel := ghRelease{TagName: "v9.0.0"} _, err := parseRelease("1.0.0", rel) diff --git a/client/vpncore/logger.go b/client/vpncore/logger.go index f0a0350..16a6980 100644 --- a/client/vpncore/logger.go +++ b/client/vpncore/logger.go @@ -47,7 +47,7 @@ func InitLogger() error { } logPath := filepath.Join(logDir, fmt.Sprintf("entangled-%s.log", time.Now().Format("2006-01-02"))) - f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) if err != nil { return err } diff --git a/client/vpncore/peer.go b/client/vpncore/peer.go index ba2a157..67b7ee0 100644 --- a/client/vpncore/peer.go +++ b/client/vpncore/peer.go @@ -108,7 +108,12 @@ func (pm *PeerManager) Stop() { return default: close(pm.stop) + conn := pm.sharedConn pm.mu.Unlock() + // Unblock reader() stuck in ReadFromUDP before Wait. + if conn != nil { + _ = conn.Close() + } } pm.wg.Wait() } @@ -507,29 +512,18 @@ func (pm *PeerManager) processPeerPayload(srcVIP string, payload []byte, isRelay func (pm *PeerManager) findPeerByAddr(addr string) *Peer { pm.mu.RLock() defer pm.mu.RUnlock() - host := addr - if h, _, err := net.SplitHostPort(addr); err == nil { - host = h - } for _, peer := range pm.peers { peer.mu.Lock() var match bool - if peer.remoteUDP != nil { - match = peer.remoteUDP.String() == addr || peer.remoteUDP.IP.String() == host - if match && peer.remoteUDP.String() != addr { - if a, err := net.ResolveUDPAddr("udp", addr); err == nil { - peer.remoteUDP = a - // Keep learned mapping in the candidate set for later sends. - peer.candidates = prependCandidate(peer.candidates, a) - } - } + if peer.remoteUDP != nil && peer.remoteUDP.String() == addr { + match = true } if !match { for _, c := range peer.candidates { if c == nil { continue } - if c.String() == addr || c.IP.String() == host { + if c.String() == addr { match = true if a, err := net.ResolveUDPAddr("udp", addr); err == nil { peer.remoteUDP = a diff --git a/client/vpncore/signaling.go b/client/vpncore/signaling.go index 9e7a507..e8b0955 100644 --- a/client/vpncore/signaling.go +++ b/client/vpncore/signaling.go @@ -100,7 +100,8 @@ func (s *SignalingClient) logf(format string, args ...interface{}) { } // normalizeWSAddr picks ws/wss and a bare host[:port]. -// https://host:8080 → ws (plain servers; browser URL habit). Explicit wss:// always TLS. +// https:// always maps to wss (no silent cleartext downgrade on non-443). +// Plain servers: use host:port, http://, or explicit ws://. func normalizeWSAddr(serverAddr string) (scheme, host string) { raw := strings.TrimSpace(serverAddr) lower := strings.ToLower(raw) @@ -130,14 +131,10 @@ func normalizeWSAddr(serverAddr string) (scheme, host string) { scheme = "ws" switch { - case hadWSS: + case hadWSS, hadHTTPS: scheme = "wss" case hadWS, hadHTTP: scheme = "ws" - case hadHTTPS: - if !hasPort || port == "443" { - scheme = "wss" - } case hasPort && port == "443": scheme = "wss" } @@ -416,6 +413,19 @@ func (s *SignalingClient) Close() { s.mu.Lock() s.intentional = true s.mu.Unlock() + s.closeConn() +} + +// CloseAttempt aborts a dial/auth try without marking intentional disconnect +// (reconnectLoop must keep retrying without tearing TUN down). +func (s *SignalingClient) CloseAttempt() { + s.mu.Lock() + s.intentional = false + s.mu.Unlock() + s.closeConn() +} + +func (s *SignalingClient) closeConn() { s.closeOnce.Do(func() { close(s.done) s.mu.Lock() diff --git a/client/vpncore/signaling_addr_test.go b/client/vpncore/signaling_addr_test.go index 8f84bfe..99b3b33 100644 --- a/client/vpncore/signaling_addr_test.go +++ b/client/vpncore/signaling_addr_test.go @@ -7,14 +7,14 @@ func TestNormalizeWSAddr(t *testing.T) { in, scheme, host string }{ {"192.0.2.10:8080", "ws", "192.0.2.10:8080"}, - {"https://192.0.2.10:8080", "ws", "192.0.2.10:8080"}, + {"https://192.0.2.10:8080", "wss", "192.0.2.10:8080"}, {"http://192.0.2.10:8080", "ws", "192.0.2.10:8080"}, {"ws://192.0.2.10:8080", "ws", "192.0.2.10:8080"}, {"wss://example.com", "wss", "example.com"}, {"https://example.com", "wss", "example.com"}, {"https://example.com:443", "wss", "example.com:443"}, {"example.com:443", "wss", "example.com:443"}, - {"https://host:8080/path", "ws", "host:8080"}, + {"https://host:8080/path", "wss", "host:8080"}, } for _, c := range cases { scheme, host := normalizeWSAddr(c.in) diff --git a/client/vpncore/version.go b/client/vpncore/version.go index f61d97e..0fe23f1 100644 --- a/client/vpncore/version.go +++ b/client/vpncore/version.go @@ -1,3 +1,3 @@ package vpncore -const AppVersion = "1.4.0" +const AppVersion = "1.4.1" diff --git a/client/vpncore/vpn.go b/client/vpncore/vpn.go index 7064bcb..d2c421a 100644 --- a/client/vpncore/vpn.go +++ b/client/vpncore/vpn.go @@ -62,6 +62,7 @@ type VPNCore struct { OnSystemChat func(text string) OnRoomDeleted func(name string) OnOwnerToken func(room, token string) // persist capability token locally + OnRoomJoined func(room, password string) // confirmed join — persist last-room / saved network } func (v *VPNCore) log(format string, args ...interface{}) { @@ -157,6 +158,25 @@ func (v *VPNCore) Start() error { return err } + // Wait for auth (same timeout as reconnectLoop) before advertising Connected. + v.mu.Lock() + authOK := v.authOK + v.mu.Unlock() + if authOK != nil { + select { + case <-authOK: + case <-time.After(15 * time.Second): + v.log("Start auth timeout") + v.closeSignalingAttempt() + v.mu.Lock() + v.status.Phase = "error" + v.mu.Unlock() + v.updateStatus() + v.Stop() + return fmt.Errorf("authentication timed out") + } + } + v.mu.Lock() v.status.Connected = true v.status.Reconnecting = false @@ -270,41 +290,16 @@ func (v *VPNCore) wireSignalingHandlers(signaling *SignalingClient) { } v.updatePeers() - if err := v.tun.Start(virtualIP); err != nil { - v.log("TUN start error: %v", err) - if v.OnError != nil { - v.OnError(tunStartErrorHint(err)) - } + pass := "" + v.mu.Lock() + if v.lastRoomName == room { + pass = v.lastRoomPass } else { - v.log("TUN adapter started") - v.router = NewPacketRouter(virtualIP, v.peers) - v.router.OnLog = v.log - v.tun.OnPacket = func(data []byte) { - v.router.HandleTUNPacket(data) - } - v.peers.OnPacket = func(fromID string, data []byte) { - v.router.HandlePeerPacket(fromID, data) - } - v.peers.OnChat = func(fromID, nickname, message string, isDM bool) { - if v.OnChat != nil { - v.OnChat(fromID, nickname, message, isDM) - } - } - v.router.OnSendToTUN = func(data []byte) { - v.tun.Write(data) - } - v.router.OnAddRoute = func(peerIP string) { - v.tun.AddRoute(peerIP) - } - - for _, p := range v.peers.GetPeers() { - p.mu.Lock() - vip := p.VirtualIP - p.mu.Unlock() - if vip != "" { - v.router.AddRoute(vip) - } - } + v.lastRoomName = room + } + v.mu.Unlock() + if v.OnRoomJoined != nil { + v.OnRoomJoined(room, pass) } v.peers.SendWSRelay = func(toID string, data []byte) error { @@ -325,6 +320,9 @@ func (v *VPNCore) wireSignalingHandlers(signaling *SignalingClient) { signaling.OnRelayData = func(fromID string, data []byte) { v.peers.HandleRelayPacket(data) } + + // TUN setup off the WS read path so signaling stays responsive (B-16). + go v.setupTUNAfterJoin(virtualIP) } signaling.OnRoomDeleted = func(name string) { @@ -534,10 +532,11 @@ func (v *VPNCore) reconnectLoop(gen int, room, pass string) { } func (v *VPNCore) cleanupRoomLocal() { - if v.tun != nil { - v.tun.Close() - } v.mu.Lock() + if v.listenerConn != nil { + _ = v.listenerConn.Close() + v.listenerConn = nil + } if v.relay != nil { v.relay.Stop() v.relay = nil @@ -549,6 +548,10 @@ func (v *VPNCore) cleanupRoomLocal() { v.lastRoomName = "" v.lastRoomPass = "" v.mu.Unlock() + if v.tun != nil { + v.tun.Close() + } + v.peers.Stop() v.peers.Clear() v.updateStatus() v.updatePeers() @@ -584,7 +587,49 @@ func (v *VPNCore) closeSignalingAttempt() { v.signaling = nil v.mu.Unlock() if sig != nil { - sig.Close() + sig.CloseAttempt() + } +} + +func (v *VPNCore) setupTUNAfterJoin(virtualIP string) { + if v.tun == nil { + return + } + if err := v.tun.Start(virtualIP); err != nil { + v.log("TUN start error: %v", err) + if v.OnError != nil { + v.OnError(tunStartErrorHint(err)) + } + return + } + v.log("TUN adapter started") + v.router = NewPacketRouter(virtualIP, v.peers) + v.router.OnLog = v.log + v.tun.OnPacket = func(data []byte) { + v.router.HandleTUNPacket(data) + } + v.peers.OnPacket = func(fromID string, data []byte) { + v.router.HandlePeerPacket(fromID, data) + } + v.peers.OnChat = func(fromID, nickname, message string, isDM bool) { + if v.OnChat != nil { + v.OnChat(fromID, nickname, message, isDM) + } + } + v.router.OnSendToTUN = func(data []byte) { + v.tun.Write(data) + } + v.router.OnAddRoute = func(peerIP string) { + v.tun.AddRoute(peerIP) + } + + for _, p := range v.peers.GetPeers() { + p.mu.Lock() + vip := p.VirtualIP + p.mu.Unlock() + if vip != "" { + v.router.AddRoute(vip) + } } } @@ -647,8 +692,8 @@ func (v *VPNCore) CreateRoom(name, password string) error { if v.signaling == nil { return fmt.Errorf("not connected") } + // Remember password for confirmed-join callback; do not treat as joined yet. v.mu.Lock() - v.lastRoomName = name v.lastRoomPass = password v.mu.Unlock() v.peers.Clear() @@ -662,7 +707,6 @@ func (v *VPNCore) JoinRoom(name, password string) error { return fmt.Errorf("not connected") } v.mu.Lock() - v.lastRoomName = name v.lastRoomPass = password tok := v.ownerTokenLocked(name) v.mu.Unlock() @@ -721,13 +765,14 @@ func (v *VPNCore) startPeerListener() { v.listenerMu.Lock() defer v.listenerMu.Unlock() - v.peers.Stop() + // Close UDP before Stop so reader can exit (B-04). v.mu.Lock() if v.listenerConn != nil { - v.listenerConn.Close() + _ = v.listenerConn.Close() v.listenerConn = nil } v.mu.Unlock() + v.peers.Stop() addr, _ := net.ResolveUDPAddr("udp4", ":0") conn, err := net.ListenUDP("udp4", addr) diff --git a/electron/main.mjs b/electron/main.mjs index 4abf73b..01271f5 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -1,5 +1,6 @@ import { app, BrowserWindow, clipboard, ipcMain, shell } from 'electron' import { spawn, spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' import { createInterface } from 'node:readline' import fs from 'node:fs' import path from 'node:path' @@ -36,6 +37,12 @@ function sidecarPath() { } /** AppImage/squashfs can't keep file capabilities — copy sidecar to a writable path and setcap. */ +function fileSHA256(p) { + const h = createHash('sha256') + h.update(fs.readFileSync(p)) + return h.digest('hex') +} + function resolveRunnableSidecar() { const bundled = sidecarPath() if (process.platform !== 'linux' || !app.isPackaged) { @@ -44,19 +51,21 @@ function resolveRunnableSidecar() { if (!fs.existsSync(bundled)) { throw new Error(`sidecar binary missing: ${bundled}`) } + const bundledHash = fileSHA256(bundled) const destDir = path.join(app.getPath('userData'), 'bin') const dest = path.join(destDir, 'entangled-sidecar') fs.mkdirSync(destDir, { recursive: true }) - const bundledStat = fs.statSync(bundled) let needCopy = !fs.existsSync(dest) if (!needCopy) { - const destStat = fs.statSync(dest) - needCopy = bundledStat.size !== destStat.size || bundledStat.mtimeMs > destStat.mtimeMs + needCopy = fileSHA256(dest) !== bundledHash } if (needCopy) { fs.copyFileSync(bundled, dest) fs.chmodSync(dest, 0o755) } + if (fileSHA256(dest) !== bundledHash) { + throw new Error('sidecar integrity check failed before setcap') + } ensureLinuxTunCaps(dest) return dest } @@ -187,7 +196,7 @@ function createWindow() { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, - sandbox: false, + sandbox: true, }, }) diff --git a/electron/scripts/install-linux.sh b/electron/scripts/install-linux.sh index 50a336d..9984e9d 100755 --- a/electron/scripts/install-linux.sh +++ b/electron/scripts/install-linux.sh @@ -37,7 +37,7 @@ cat > "$APPS/entangledvpn.desktop" < /dev/null; then - echo "[1/3] Installing Go..." + echo "[1/4] Installing Go..." wget -q https://go.dev/dl/go1.22.5.linux-amd64.tar.gz sudo tar -C /usr/local -xzf go1.22.5.linux-amd64.tar.gz export PATH=$PATH:/usr/local/go/bin echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc rm go1.22.5.linux-amd64.tar.gz else - echo "[1/3] Go already installed: $(go version)" + echo "[1/4] Go already installed: $(go version)" fi -# Build server SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SERVER_DIR="$SCRIPT_DIR/../server" +INSTALL_DIR="/var/lib/entangled" +BIN_DIR="/usr/local/bin" -echo "[2/3] Building server..." +echo "[2/4] Building server..." cd "$SERVER_DIR" go mod tidy go build -o entangled-server . -# Install as systemd service -echo "[3/3] Installing systemd service..." +echo "[3/4] Installing binary + data dir..." +sudo mkdir -p "$INSTALL_DIR" +sudo chown nobody:nogroup "$INSTALL_DIR" 2>/dev/null || sudo chown nobody:nobody "$INSTALL_DIR" +sudo install -m 0755 "$SERVER_DIR/entangled-server" "$BIN_DIR/entangled-server" + +TOKEN_FILE="$INSTALL_DIR/server.token" +ALLOW_OPEN=0 +if [[ "${ENTANGLED_ALLOW_OPEN_JOIN:-}" == "1" ]]; then + ALLOW_OPEN=1 +fi + +if [[ -z "${ENTANGLED_TOKEN:-}" ]]; then + if [[ -f "$TOKEN_FILE" ]]; then + ENTANGLED_TOKEN="$(sudo cat "$TOKEN_FILE")" + elif [[ "$ALLOW_OPEN" -eq 1 ]]; then + ENTANGLED_TOKEN="" + echo "WARNING: ENTANGLED_ALLOW_OPEN_JOIN=1 — server will accept unauthenticated joins." + else + ENTANGLED_TOKEN="$(openssl rand -hex 24)" + echo "$ENTANGLED_TOKEN" | sudo tee "$TOKEN_FILE" >/dev/null + sudo chmod 0600 "$TOKEN_FILE" + sudo chown nobody:nogroup "$TOKEN_FILE" 2>/dev/null || sudo chown nobody:nobody "$TOKEN_FILE" + echo "Generated server token (also in $TOKEN_FILE)." + echo "Clients must set this as Server Token. Put TLS in front (nginx/caddy) for production." + fi +fi + +echo "[4/4] Installing systemd service..." sudo tee /etc/systemd/system/entangled-server.service > /dev/null < 256*1024 { + c.sendError("relay_data too large") + return + } c.mu.RLock() room := c.Room c.mu.RUnlock() diff --git a/server/relay.go b/server/relay.go index 714cc7c..e939d9d 100644 --- a/server/relay.go +++ b/server/relay.go @@ -43,7 +43,7 @@ func NewRelay() *Relay { // parseRelayReg accepts tokenized REG only: // // [magic:4][type:1][token_len:1][token][vip] -func (r *Relay) parseRelayReg(pkt []byte) (vip string, ok bool) { +func (r *Relay) parseRelayReg(pkt []byte, remote string) (vip string, ok bool) { n := len(pkt) if n < 6 { return "", false @@ -59,8 +59,8 @@ func (r *Relay) parseRelayReg(pkt []byte) (vip string, ok bool) { log.Printf("Relay REG rejected (unparseable vip)") return "", false } - if r.Hub == nil || !r.Hub.ValidateRelayReg(token, cand) { - log.Printf("Relay REG rejected for vip=%s (bad/expired token)", cand) + if r.Hub == nil || !r.Hub.ValidateRelayReg(token, cand, remote) { + log.Printf("Relay REG rejected for vip=%s (bad/expired token or UDP bind mismatch)", cand) return "", false } return cand, true @@ -139,7 +139,7 @@ func (r *Relay) readLoop() { switch msgType { case relayTypeReg: - vip, ok := r.parseRelayReg(buf[:n]) + vip, ok := r.parseRelayReg(buf[:n], addr.String()) if !ok || vip == "" { continue } diff --git a/server/room.go b/server/room.go index a3dd925..6e80792 100644 --- a/server/room.go +++ b/server/room.go @@ -2,6 +2,7 @@ package main import ( "crypto/rand" + "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" @@ -10,6 +11,7 @@ import ( "log" "net" "os" + "path/filepath" "strings" "sync" "time" @@ -18,30 +20,33 @@ import ( ) type StoredRoom struct { - Name string `json:"name"` - PasswordHash string `json:"password_hash,omitempty"` - Password string `json:"password,omitempty"` // legacy plaintext; migrated on load - CreatedAt string `json:"created_at"` - OwnerID string `json:"owner_id"` - OwnerToken string `json:"owner_token,omitempty"` - OwnerPubKey string `json:"owner_pubkey,omitempty"` // legacy metadata only; not used for auth + Name string `json:"name"` + PasswordHash string `json:"password_hash,omitempty"` + Password string `json:"password,omitempty"` // legacy plaintext; migrated on load + CreatedAt string `json:"created_at"` + OwnerID string `json:"owner_id"` + OwnerToken string `json:"owner_token,omitempty"` // legacy plaintext; migrated to hash on save + OwnerTokenHash string `json:"owner_token_hash,omitempty"` + OwnerPubKey string `json:"owner_pubkey,omitempty"` // legacy metadata only; not used for auth } type Room struct { - Name string - PasswordHash string - OwnerID string - OwnerToken string - OwnerPubKey string // legacy metadata only; not used for auth - CreatedAt string - Clients map[string]*Client - mu sync.RWMutex + Name string + PasswordHash string + OwnerID string + OwnerToken string // in-memory plaintext when freshly issued + OwnerTokenHash string + OwnerPubKey string // legacy metadata only; not used for auth + CreatedAt string + Clients map[string]*Client + mu sync.RWMutex } type RelayTokenEntry struct { VirtualIP string ClientID string Expiry time.Time + BoundUDP string // first successful REG remote; empty until bound } func NewRoom(name, passwordHash, ownerID, ownerToken string) *Room { @@ -77,25 +82,31 @@ func (r *Room) isOwner(c *Client) bool { } func (r *Room) validOwnerToken(token string) bool { - if r == nil || r.OwnerToken == "" || token == "" { + if r == nil || token == "" { return false } - return subtle.ConstantTimeCompare([]byte(r.OwnerToken), []byte(token)) == 1 + if r.OwnerToken != "" { + return subtle.ConstantTimeCompare([]byte(r.OwnerToken), []byte(token)) == 1 + } + if r.OwnerTokenHash != "" { + sum := sha256.Sum256([]byte(token)) + got := hex.EncodeToString(sum[:]) + return subtle.ConstantTimeCompare([]byte(r.OwnerTokenHash), []byte(got)) == 1 + } + return false } -// claimOwner binds this session as owner when token matches, or (legacy) -// when the room has no token yet — first successful claim after upgrade. +// claimOwner binds this session as owner when token matches. +// Empty-token first-claimer reclaim is refused (fail closed). func (r *Room) claimOwner(c *Client, token string) (isOwner bool, issuedToken string) { if r == nil || c == nil { return false, "" } if r.validOwnerToken(token) { r.OwnerID = c.ID - return true, r.OwnerToken - } - if r.OwnerToken == "" { - r.OwnerToken = newOwnerToken() - r.OwnerID = c.ID + if r.OwnerToken == "" && token != "" { + r.OwnerToken = token + } return true, r.OwnerToken } if r.isOwner(c) { @@ -138,19 +149,39 @@ func verifyPassword(stored, password string) bool { func newRelayToken() string { b := make([]byte, 24) - rand.Read(b) + if _, err := rand.Read(b); err != nil { + log.Printf("newRelayToken: rand.Read failed: %v", err) + return "" + } return hex.EncodeToString(b) } +func ownerTokenHash(token string) string { + if token == "" { + return "" + } + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + func (r *Room) Broadcast(msg Message) { data, err := json.Marshal(msg) if err != nil { log.Printf("Room.Broadcast marshal error: %v", err) return } + critical := isCriticalSignaling(msg.Type) r.mu.RLock() defer r.mu.RUnlock() for _, c := range r.Clients { + if critical { + select { + case c.Send <- data: + case <-time.After(2 * time.Second): + log.Printf("Room.Broadcast: critical %s drop after timeout for %s", msg.Type, c.ID) + } + continue + } select { case c.Send <- data: default: @@ -164,18 +195,38 @@ func (r *Room) BroadcastExcept(exclude *Client, msg Message) { log.Printf("Room.BroadcastExcept marshal error: %v", err) return } + critical := isCriticalSignaling(msg.Type) r.mu.RLock() defer r.mu.RUnlock() for _, c := range r.Clients { - if c != exclude { + if c == exclude { + continue + } + if critical { select { case c.Send <- data: - default: + case <-time.After(2 * time.Second): + log.Printf("Room.BroadcastExcept: critical %s drop after timeout for %s", msg.Type, c.ID) } + continue + } + select { + case c.Send <- data: + default: } } } +func isCriticalSignaling(t string) bool { + switch t { + case "auth_ok", "auth_fail", "room_joined", "room_error", "room_deleted", + "peer_joined", "peer_left", "peer_updated", "error": + return true + default: + return false + } +} + func (r *Room) GetPeersList(excludeID string) []map[string]string { r.mu.RLock() defer r.mu.RUnlock() @@ -214,7 +265,7 @@ func (h *Hub) issueRelayToken(client *Client, vip string) string { return token } -func (h *Hub) ValidateRelayReg(token, vip string) bool { +func (h *Hub) ValidateRelayReg(token, vip, remote string) bool { if token == "" || vip == "" { return false } @@ -231,6 +282,11 @@ func (h *Hub) ValidateRelayReg(token, vip string) bool { if e.VirtualIP != vip { return false } + if e.BoundUDP == "" { + e.BoundUDP = remote + } else if remote != "" && e.BoundUDP != remote { + return false + } // refresh on successful use e.Expiry = time.Now().Add(3 * time.Minute) return true @@ -489,6 +545,9 @@ func (h *Hub) DeleteRoom(name, ownerToken string, requester *Client) { } func roomsPath() string { + if d := os.Getenv("ENTANGLED_DATA_DIR"); d != "" { + return filepath.Join(d, "rooms.json") + } return "rooms.json" } @@ -497,13 +556,18 @@ func (h *Hub) SaveRooms() { defer h.mu.Unlock() rooms := make(map[string]StoredRoom) for name, room := range h.Rooms { + hash := room.OwnerTokenHash + if hash == "" && room.OwnerToken != "" { + hash = ownerTokenHash(room.OwnerToken) + room.OwnerTokenHash = hash + } rooms[name] = StoredRoom{ - Name: room.Name, - PasswordHash: room.PasswordHash, - CreatedAt: room.CreatedAt, - OwnerID: room.OwnerID, - OwnerToken: room.OwnerToken, - OwnerPubKey: room.OwnerPubKey, + Name: room.Name, + PasswordHash: room.PasswordHash, + CreatedAt: room.CreatedAt, + OwnerID: room.OwnerID, + OwnerTokenHash: hash, + OwnerPubKey: room.OwnerPubKey, } } data, err := json.MarshalIndent(rooms, "", " ") @@ -511,10 +575,18 @@ func (h *Hub) SaveRooms() { log.Printf("failed to marshal rooms: %v", err) return } - if err := os.WriteFile(roomsPath(), data, 0600); err != nil { + path := roomsPath() + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0600); err != nil { log.Printf("failed to save rooms: %v", err) + return + } + if err := os.Rename(tmp, path); err != nil { + log.Printf("failed to rename rooms: %v", err) + _ = os.Remove(tmp) + return } - log.Printf("saved %d rooms to %s", len(rooms), roomsPath()) + log.Printf("saved %d rooms to %s", len(rooms), path) } func (h *Hub) LoadRooms() { @@ -546,14 +618,21 @@ func (h *Hub) LoadRooms() { hash = h2 migrated = true } + tokenHash := sr.OwnerTokenHash + ownerToken := "" + if tokenHash == "" && sr.OwnerToken != "" { + tokenHash = ownerTokenHash(sr.OwnerToken) + migrated = true + } h.Rooms[name] = &Room{ - Name: sr.Name, - PasswordHash: hash, - OwnerID: sr.OwnerID, - OwnerToken: sr.OwnerToken, - OwnerPubKey: sr.OwnerPubKey, - CreatedAt: sr.CreatedAt, - Clients: make(map[string]*Client), + Name: sr.Name, + PasswordHash: hash, + OwnerID: sr.OwnerID, + OwnerToken: ownerToken, + OwnerTokenHash: tokenHash, + OwnerPubKey: sr.OwnerPubKey, + CreatedAt: sr.CreatedAt, + Clients: make(map[string]*Client), } } log.Printf("loaded %d rooms from %s", len(stored), roomsPath()) diff --git a/server/security_test.go b/server/security_test.go index ab519db..08be175 100644 --- a/server/security_test.go +++ b/server/security_test.go @@ -57,13 +57,19 @@ func TestRelayTokenValidate(t *testing.T) { c := &Client{ID: "abc", VirtualIP: "10.242.0.2"} h.Clients[c.ID] = c tok := h.issueRelayToken(c, "10.242.0.2") - if !h.ValidateRelayReg(tok, "10.242.0.2") { + if !h.ValidateRelayReg(tok, "10.242.0.2", "1.2.3.4:5") { t.Fatal("expected valid token") } - if h.ValidateRelayReg(tok, "10.242.0.3") { + if h.ValidateRelayReg(tok, "10.242.0.2", "9.9.9.9:9") { + t.Fatal("UDP bind mismatch should fail") + } + if !h.ValidateRelayReg(tok, "10.242.0.2", "1.2.3.4:5") { + t.Fatal("same UDP bind should still work") + } + if h.ValidateRelayReg(tok, "10.242.0.3", "1.2.3.4:5") { t.Fatal("vip mismatch should fail") } - if h.ValidateRelayReg("nope", "10.242.0.2") { + if h.ValidateRelayReg("nope", "10.242.0.2", "1.2.3.4:5") { t.Fatal("bad token should fail") } if !h.VIPAssigned("10.242.0.2") { @@ -86,7 +92,7 @@ func TestParseRelayRegTokenOnly(t *testing.T) { binary.BigEndian.PutUint32(legacy[0:4], relayMagic) legacy[4] = relayTypeReg copy(legacy[5:], "10.242.0.2") - if _, ok := r.parseRelayReg(legacy); ok { + if _, ok := r.parseRelayReg(legacy, "1.2.3.4:5"); ok { t.Fatal("legacy VIP-only REG should be rejected") } @@ -97,7 +103,7 @@ func TestParseRelayRegTokenOnly(t *testing.T) { pkt[5] = byte(len(tok)) copy(pkt[6:], tok) copy(pkt[6+len(tok):], "10.242.0.2") - vip, ok := r.parseRelayReg(pkt) + vip, ok := r.parseRelayReg(pkt, "1.2.3.4:5") if !ok || vip != "10.242.0.2" { t.Fatalf("token reg failed: ok=%v vip=%q", ok, vip) } @@ -108,7 +114,7 @@ func TestParseRelayRegTokenOnly(t *testing.T) { emptyTok[4] = relayTypeReg emptyTok[5] = 0 copy(emptyTok[6:], "10.242.0.2") - if _, ok := r.parseRelayReg(emptyTok); ok { + if _, ok := r.parseRelayReg(emptyTok, "1.2.3.4:5"); ok { t.Fatal("empty-token REG should fail") } @@ -119,7 +125,7 @@ func TestParseRelayRegTokenOnly(t *testing.T) { bad[5] = 4 copy(bad[6:], "nope") copy(bad[10:], "10.242.0.2") - if _, ok := r.parseRelayReg(bad); ok { + if _, ok := r.parseRelayReg(bad, "1.2.3.4:5"); ok { t.Fatal("bad token should fail") } } @@ -162,17 +168,12 @@ func TestRoomOwnerToken(t *testing.T) { } } -func TestLegacyOwnerClaimIssuesToken(t *testing.T) { +func TestLegacyOwnerClaimRefused(t *testing.T) { room := NewRoom("legacy", "", "stale-id", "") first := &Client{ID: "first"} ok, tok := room.claimOwner(first, "") - if !ok || tok == "" { - t.Fatal("legacy room should issue owner token on first claim") - } - second := &Client{ID: "second"} - ok, _ = room.claimOwner(second, "") - if ok { - t.Fatal("second joiner without token must not become owner") + if ok || tok != "" { + t.Fatal("empty-token reclaim must be refused") } }