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
7 changes: 7 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' \
Expand All @@ -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'
23 changes: 20 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
103 changes: 70 additions & 33 deletions client/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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() {
Expand All @@ -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 {
Expand All @@ -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() {
Expand Down Expand Up @@ -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()
Expand All @@ -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) {
Expand All @@ -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).
Expand Down
Loading
Loading