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
65 changes: 65 additions & 0 deletions cmd/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package cmd

import (
"fmt"
"strconv"

"github.com/neozmmv/blindspot/internal/utils"
"github.com/spf13/cobra"
)

// ConfigCmd shows or changes persistent settings in ~/.blindspot/config.json.
// Settings apply to every future connect — CLI and tray alike — so nothing
// needs to be passed per session. Changes take effect on the next connect.
var ConfigCmd = &cobra.Command{
Use: "config [setting] [value]",
Short: "Show or change persistent settings",
Long: `Show or change persistent settings (stored in ~/.blindspot/config.json).

Settings:
up-mbit Force a fixed upload cap in Mbit/s. 0 (the default) means
automatic: the tunnel adapts its rate to the path on its
own, staying uncapped until real packet loss appears.
Only set this if automatic shaping misbehaves on your
link. Applies on the next connect.

Examples:
blindspot config show current settings
blindspot config up-mbit 80 force a fixed 80 Mbit/s cap
blindspot config up-mbit 0 back to automatic`,
Args: cobra.MaximumNArgs(2),
Run: func(cmd *cobra.Command, args []string) {
cfg := utils.LoadConfig()
if len(args) == 0 {
if cfg.UpMbit > 0 {
fmt.Printf("up-mbit: %d (fixed cap)\n", cfg.UpMbit)
} else {
fmt.Println("up-mbit: 0 (automatic)")
}
return
}
if args[0] != "up-mbit" {
fmt.Printf("Unknown setting %q. Available: up-mbit\n", args[0])
return
}
if len(args) == 1 {
fmt.Printf("up-mbit: %d\n", cfg.UpMbit)
return
}
v, err := strconv.Atoi(args[1])
if err != nil || v < 0 {
fmt.Println("up-mbit must be a non-negative number of Mbit/s")
return
}
cfg.UpMbit = v
if err := utils.SaveConfig(cfg); err != nil {
fmt.Println("Error saving config:", err)
return
}
if v == 0 {
fmt.Println("Upload shaping set to automatic. Takes effect on the next connect.")
} else {
fmt.Printf("Upload capped at %d Mbit/s. Takes effect on the next connect.\n", v)
}
},
}
83 changes: 83 additions & 0 deletions cmd/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"

Expand Down Expand Up @@ -75,6 +76,19 @@ var ConnectCmd = &cobra.Command{
daemon, _ := cmd.Flags().GetBool("daemon")
statusFile, _ := cmd.Flags().GetString("status-file")
insecure, _ := cmd.Flags().GetBool("insecure")
// Upload cap precedence: --up-mbit flag > BLINDSPOT_UP_MBIT env >
// persistent config ('blindspot config up-mbit N'). The config file is
// what the tray path uses — it shells out to connect without flags, and
// the UAC-elevated daemon shares the same ~/.blindspot.
upMbit, _ := cmd.Flags().GetInt("up-mbit")
if upMbit == 0 {
if v, err := strconv.Atoi(os.Getenv("BLINDSPOT_UP_MBIT")); err == nil && v > 0 {
upMbit = v
}
}
if upMbit == 0 {
upMbit = utils.LoadConfig().UpMbit
}

if len(password) < 8 && isNew {
fmt.Println("Password must be at least 8 characters long")
Expand Down Expand Up @@ -192,6 +206,9 @@ var ConnectCmd = &cobra.Command{
publicAddr string
)
peerConn := network.NewPeerConn(tr, privateKey, publicKey, psk, prologue)
if upMbit > 0 {
peerConn.SetFixedUploadRate(float64(upMbit) * 1e6 / 8)
}
quit := make(chan struct{})
var quitOnce sync.Once
closeQuit := func() { quitOnce.Do(func() { close(quit) }) }
Expand Down Expand Up @@ -333,6 +350,62 @@ var ConnectCmd = &cobra.Command{
}
}()

// TUN-side counters for the stats log: what enters from the OS, what is
// written back to the OS, and what the reverse-path filter rejects.
var tunInPkts, tunInBytes, tunOutPkts, tunOutBytes, rpfDrops, noPeerDrops atomic.Uint64

// Stats logger: once per second, append a delta line to stats.log while
// there is tunnel activity. Comparing this file across the two machines
// shows exactly where packets die: sender tx vs receiver rx is path
// loss; rx vs tun_out is local processing loss; dec_fail/replay/rekey
// indicate session-level trouble.
go func() {
logPath := filepath.Join(utils.GetBlindspotDir(), "stats.log")
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return
}
defer f.Close()
type snap struct {
txP, txB, txE, rxP, rxB, dec, rep, rek, tmo uint64
tiP, tiB, toP, toB, rpf, nop uint64
}
take := func() snap {
s := &network.Stats
return snap{
s.TxPkts.Load(), s.TxBytes.Load(), s.TxErrs.Load(),
s.RxPkts.Load(), s.RxBytes.Load(),
s.RxDecryptFail.Load(), s.RxReplayDrop.Load(),
s.Rekeys.Load(), s.Timeouts.Load(),
tunInPkts.Load(), tunInBytes.Load(),
tunOutPkts.Load(), tunOutBytes.Load(),
rpfDrops.Load(), noPeerDrops.Load(),
}
}
prev := take()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-quit:
return
case <-ticker.C:
cur := take()
if cur == prev {
continue
}
fmt.Fprintf(f,
"%s tun_in=%d/%dB tx=%d/%dB txerr=%d | rx=%d/%dB tun_out=%d/%dB dec_fail=%d replay=%d rpf=%d nopeer=%d | rekey=%d timeout=%d pace=%dbps\n",
time.Now().Format("15:04:05"),
cur.tiP-prev.tiP, cur.tiB-prev.tiB, cur.txP-prev.txP, cur.txB-prev.txB, cur.txE-prev.txE,
cur.rxP-prev.rxP, cur.rxB-prev.rxB, cur.toP-prev.toP, cur.toB-prev.toB,
cur.dec-prev.dec, cur.rep-prev.rep, cur.rpf-prev.rpf, cur.nop-prev.nop,
cur.rek-prev.rek, cur.tmo-prev.tmo, network.Stats.PaceBps.Load()*8)
prev = cur
}
}
}()

// tunBufPool recycles packet buffers across both pump directions so the
// steady state allocates nothing per packet.
tunBufPool := sync.Pool{New: func() any {
Expand Down Expand Up @@ -370,9 +443,12 @@ var ConnectCmd = &cobra.Command{
// spoofing another peer's virtual IP inside the (authenticated) tunnel.
expectedVIP, ok := addrToVIP.Load(senders[i])
if !ok || !bstun.SrcIPMatchesVirtualIP(bufs[i], expectedVIP.(string)) {
rpfDrops.Add(1)
continue
}
wr = append(wr, bufs[i])
tunOutPkts.Add(1)
tunOutBytes.Add(uint64(len(bufs[i])))
}
if len(wr) == 0 {
continue
Expand Down Expand Up @@ -407,6 +483,8 @@ var ConnectCmd = &cobra.Command{
if len(packet) < 20 || packet[0]>>4 != 4 {
continue // not an IPv4 packet
}
tunInPkts.Add(1)
tunInBytes.Add(uint64(len(packet)))
select {
case outCh <- packet:
bufs[i] = getTunBuf() // buffer ownership moved to the sender
Expand All @@ -420,13 +498,17 @@ var ConnectCmd = &cobra.Command{
// Sender: aggregate whatever the reader has produced, group consecutive
// packets by destination peer, and push each group through one batched
// encrypt+send (one counter reservation, parallel AEAD, few syscalls).
// Upload shaping happens inside SendTunBatch: adaptive by default
// (engages only when CtrlAck feedback shows path loss), or fixed when
// --up-mbit / config override it.
go func() {
pending := make([][]byte, 0, 128)
flush := func() {
for start := 0; start < len(pending); {
destIP := net.IP(pending[start][16:20]).String()
addrVal, ok := virtualIPMap.Load(destIP)
if !ok {
noPeerDrops.Add(1)
putTunBuf(pending[start]) // no peer with that virtual IP
start++
continue
Expand Down Expand Up @@ -524,6 +606,7 @@ func init() {
ConnectCmd.Flags().StringP("password", "p", "", "Session password")
ConnectCmd.Flags().BoolP("new", "n", false, "Create new session with password")
ConnectCmd.Flags().Bool("insecure", false, "Allow a plaintext http:// rendezvous (NOT recommended)")
ConnectCmd.Flags().Int("up-mbit", 0, "Force a fixed upload cap in Mbit/s (0 = automatic: adapts to the path)")
ConnectCmd.MarkFlagRequired("session")
ConnectCmd.Flags().Bool("daemon", false, "")
ConnectCmd.Flags().String("status-file", "", "")
Expand Down
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func init() {
rootCmd.AddCommand(SendCmd)
rootCmd.AddCommand(ReceiveCmd)
rootCmd.AddCommand(IdentityCmd)
rootCmd.AddCommand(ConfigCmd)
rootCmd.AddCommand(VersionCmd)
}

Expand Down
118 changes: 118 additions & 0 deletions internal/network/pace_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package network

import (
"testing"
"time"
)

// ackStep simulates one CtrlAck arriving after `elapsed`, with the session
// having sent sentB bytes / dMax packets of which dAcc were accepted.
func ackStep(p *PeerConn, s *peerSession, elapsed time.Duration, sentB uint64, dMax, dAcc uint64) {
s.paceMu.Lock()
s.lastAckAt = time.Now().Add(-elapsed)
s.paceSentB = sentB
base := s.lastAckMax
baseAcc := s.lastAckAcc
s.paceMu.Unlock()
p.handleAck(s, base+dMax, baseAcc+dAcc)
}

// TestAdaptiveShaper drives handleAck through the engage → cut → probe cycle
// and checks the control law: uncapped until real loss, engaged near the
// observed send rate on a loss event, growing again on clean utilized
// intervals, and not double-cutting within the guard window.
func TestAdaptiveShaper(t *testing.T) {
p := &PeerConn{}
s := &peerSession{}

// Baseline ack: seeds the snapshot, no rate yet.
p.handleAck(s, 100, 100)
if got := p.effectiveRate(s); got != 0 {
t.Fatalf("rate engaged with no loss signal: %v", got)
}

// Clean traffic: stays uncapped.
ackStep(p, s, 200*time.Millisecond, 2_000_000, 1000, 1000)
if got := p.effectiveRate(s); got != 0 {
t.Fatalf("rate engaged on clean interval: %v", got)
}

// 15% loss while sending 2 MB in 200ms (10 MB/s): engages at
// sendRate*cutBig = 10e6*0.6 = 6 MB/s.
ackStep(p, s, 200*time.Millisecond, 2_000_000, 1000, 850)
rate := p.effectiveRate(s)
if rate < 5e6 || rate > 7e6 {
t.Fatalf("expected ~6 MB/s after severe loss engage, got %.0f", rate)
}

// Another severe-loss ack immediately after: inside the cut guard, no
// second cut.
ackStep(p, s, 200*time.Millisecond, 1_200_000, 800, 600)
if got := p.effectiveRate(s); got != rate {
t.Fatalf("double cut within guard window: %.0f -> %.0f", rate, got)
}

// Clean and >50%% utilized: probes upward by paceGrow.
s.paceMu.Lock()
s.lastCut = time.Now().Add(-time.Second) // move past the guard
s.paceMu.Unlock()
ackStep(p, s, 200*time.Millisecond, uint64(rate*0.2), 800, 800)
if got := p.effectiveRate(s); got <= rate {
t.Fatalf("expected upward probe on clean utilized interval, got %.0f (was %.0f)", got, rate)
}

// Clean but idle (2%% utilization): rate must hold, not balloon.
before := p.effectiveRate(s)
ackStep(p, s, 200*time.Millisecond, uint64(before*0.2*0.02), 60, 60)
if got := p.effectiveRate(s); got != before {
t.Fatalf("rate changed on idle interval: %.0f -> %.0f", before, got)
}

// Tiny sample (below ackMinPkts): ignored entirely.
ackStep(p, s, 200*time.Millisecond, 10_000, 10, 2)
if got := p.effectiveRate(s); got != before {
t.Fatalf("rate reacted to sub-threshold sample: %.0f -> %.0f", before, got)
}

// Manual override wins over the adaptive rate and freezes adaptation.
p.SetFixedUploadRate(1e6)
if got := p.effectiveRate(s); got != 1e6 {
t.Fatalf("fixed override not applied: %.0f", got)
}
ackStep(p, s, 200*time.Millisecond, 2_000_000, 1000, 500)
if got := p.effectiveRate(s); got != 1e6 {
t.Fatalf("adaptive logic ran under fixed override: %.0f", got)
}
p.SetFixedUploadRate(0)
if got := p.effectiveRate(s); got != before {
t.Fatalf("adaptive rate lost after clearing override: %.0f (want %.0f)", got, before)
}
}

// TestPaceAdmitThrottles checks the virtual-clock pacer actually spaces sends:
// pushing ~3x a 10 MB/s budget's worth of bytes in a tight loop must take
// roughly the budgeted time, while an uncapped session must not sleep.
func TestPaceAdmitThrottles(t *testing.T) {
s := &peerSession{}

start := time.Now()
for range 100 {
s.paceAdmit(1500, 0)
}
if d := time.Since(start); d > 50*time.Millisecond {
t.Fatalf("uncapped paceAdmit slept: %v", d)
}

const rate = 10e6 // 10 MB/s
total := 0
start = time.Now()
for range 100 {
s.paceAdmit(15_000, rate) // 1.5 MB total = 150ms of budget
total += 15_000
}
elapsed := time.Since(start)
want := time.Duration(float64(total) / rate * float64(time.Second))
if elapsed < want/2 {
t.Fatalf("paced sends finished in %v, want >= ~%v", elapsed, want)
}
}
Loading
Loading