Compare commits

10 Commits

Author SHA1 Message Date
wmantly ef9d4b004b chore: remove stale committed binaries (theta-agent, theta-agent-linux-amd64)
These were never used by theta-agent itself -- only by theta-suite's
setup.sh, which installed the committed theta-agent-linux-amd64
directly instead of downloading a current release binary (this repo's
own install.sh has always downloaded from GitHub Releases instead).
A stale committed binary has caused this exact class of bug at least
twice before (see the v1.5.0 CHANGELOG entry: an old committed binary
predating join-key support meant a fix in this repo never reached
hosts installed via setup.sh) -- most recently, an installed agent
whose `update` command 404'd against a dead SSO /resources/ URL that
self-update moved away from months ago, because the binary setup.sh
had installed predated that fix entirely.

theta-suite's setup.sh is being changed in the same pass to download
https://github.com/theta42/theta-agent/releases/latest/download/theta-agent-linux-amd64
instead, matching this repo's own install.sh and permanently closing
this bug class rather than just fixing today's instance of it. Both
files were already .gitignore'd (theta-agent, theta-agent-*) -- they'd
been force-added at some point, bypassing that.
2026-08-10 21:59:17 -04:00
wmantly 4074f108c1 Merge pull request #14 from theta42/feat/tray-icons
feat(icons): clean tray badge set + Start menu/installer icon
2026-08-10 18:50:43 -07:00
wmantly 12d55a4454 feat(icons): clean tray badge set + Start menu/installer icon
New cmd/icon-gen renders the four state badges (Red/Yellow/Green/Blue) as a
rounded-square badge with a subtle vertical gradient and a crisp white theta,
256px with 4x4 supersampling. The tray embeds these (icons.go, generated) and
builds a proper multi-size Windows ICO (16..256) via exact box filtering --
replacing the old flat 48px circle and nearest-neighbour scaling.

The installer now bundles theta-agent.ico (multi-size, Blue badge) and uses it
for the Start menu 'Theta Agent Tray' shortcut, the setup.exe icon
(SetupIconFile), and the uninstaller display icon. Dead duplicate icon arrays
in the root package (tray_icons.go) removed.
2026-08-10 18:49:30 -07:00
wmantly 1b332cacab fix(installer): derive installer version from the git tag, not a hardcoded default
installer.iss had #define MyAppVersion "2.1.0", so every release's setup.exe
(and its AppVersion/VersionInfoVersion) self-identified as 2.1.0 regardless of
the actual tag. setup-build-env.ps1 now passes /DMyAppVersion derived from
GITHUB_REF_NAME (CI tag run) or git describe, falling back to a 0.0.0-dev
placeholder in the .iss only for bare manual compiles.
2026-08-10 17:41:37 -07:00
wmantly 52eba72613 fix(local-route): Unix addHostRoute/delHostRoute didn't tolerate benign errors
Caught by running the actual test suite on Linux (go test ./...) after
pulling v2.2.0 -- local_route_test.go is a shared cross-platform suite
exercising the same "already exists" / "not found" tolerance for both
platforms' addHostRoute/delHostRoute, but local_route_windows.go had the
tolerance checks and local_route_unix.go didn't:

- addHostRoute (unix): had no "already exists" check at all. `ip route
  replace` is idempotent in real usage so this rarely bites in practice,
  but the implementation should match its own test coverage rather than
  relying on that.
- delHostRoute (unix): checked "no such process"/"cannot find" but not
  "route not found", the fixture text the shared test uses (and a real
  message some `ip route del` failures produce).

Both now mirror local_route_windows.go's tolerance logic. go test ./...
passes on Linux again.
2026-08-10 20:35:12 -04:00
wmantly c7d599de85 release(v2.2.0): Windows local-discovery, route pinning, prompt reconnect 2026-08-10 17:31:05 -07:00
wmantly 230b7fb172 Merge pull request #13 from theta42/feat/windows-local-discovery
feat(discovery): Windows local-discovery + route pinning + prompt reconnect
2026-08-10 17:25:28 -07:00
wmantly b2ad8f4844 feat(discovery): Windows local-discovery + local route pinning + prompt reconnect
Completes the mDNS local-discovery feature on Windows (was Linux-only since
v2.1.2). Three parts:

1. Windows hosts override (hosts_override_windows.go): %SystemRoot%...\\hosts,
   CRLF-aware read/write, ipconfig /flushdns after each change. The agent runs
   as a SYSTEM service so elevation is a non-issue. hosts_override.go split
   into shared rewrite logic + platform files; the hosts tests now run the real
   Windows write path on CI instead of skipping.

2. Local route pinning (local_route*.go): the hosts override only fixes name
   resolution -- the packet path is the routing table's job. If the WG mesh
   tunnel is up with AllowedIPs covering the LAN (or full-tunnel 0.0.0.0/0) it
   swallows the direct connection. Discovery now pins a /32 host route via the
   owning local interface (route.exe metric 1 on Windows, ip route replace on
   Linux) and drops it on revert. Closes a gap in the shipped Linux path too.

3. Prompt reconnect: apply/revert signals the WS loop so it reconnects
   immediately instead of waiting out the 5s backoff.

Route/hosts code is injectable + unit tested; go test passes natively on
Windows (this machine), and linux/amd64 + windows/arm64 cross-builds are clean.
2026-08-10 17:24:21 -07:00
wmantly a3eeed8112 release(v2.1.3): fix Windows CI failure from v2.1.2's release build
release / agent-darwin-amd64 (push) Successful in 17s
release / agent-darwin-arm64 (push) Successful in 26s
release / agent-linux-amd64 (push) Successful in 29s
release / agent-linux-arm64 (push) Successful in 29s
release / agent-windows-amd64 (push) Successful in 29s
release / agent-linux-arm7 (push) Successful in 30s
release / agent-windows-arm64 (push) Successful in 28s
release / tray/helper/setup (push) Has been cancelled
release / Attach to GitHub release (push) Has been cancelled
2026-08-10 19:12:04 -04:00
wmantly f76f50c704 fix(test): skip Linux-only hosts-file tests on non-Linux CI runners
v2.1.2's release CI failed on the Windows build leg: TestApplyHostsOverride_*
call applyHostsOverride(), which correctly refuses unconditionally on
non-Linux (hosts_override.go) -- but the tests didn't account for `go
test ./...` running on every platform the CI matrix builds for, only
Linux. Skip them on non-Linux with a clear reason instead.
2026-08-10 19:11:38 -04:00
22 changed files with 3973 additions and 86 deletions
+22
View File
@@ -5,6 +5,28 @@ All notable changes to the `theta-agent` daemon will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Changed
- **New tray icon set** (`cmd/icon-gen`, generated `cmd/theta-agent-tray/icons.go`) — the state badges (Red/Yellow/Green/Blue) are now a rounded-square badge with a subtle vertical gradient and a crisp white theta glyph, rendered at 256px with 4x4 supersampling instead of the old flat 48px circle. Windows gets a proper multi-size ICO (16/24/32/48/64/128/256) built with an exact box filter (was nearest-neighbour over three sizes), so the tray/taskbar icon is sharp at every DPI.
- **Start menu / installer icon** — `installer/windows/theta-agent.ico` (multi-size, Blue badge) is bundled by the installer and used for the Start menu "Theta Agent Tray" shortcut, the setup.exe's own icon (`SetupIconFile`), and the uninstaller's display icon.
- Removed the dead duplicate icon byte arrays in the root package's `tray_icons.go` (nothing referenced them; the tray binary carries its own copy).
## [v2.2.0] - 2026-08-10
### Added
- **Windows mDNS local-discovery** (`hosts_override_windows.go`) — completes the Linux mechanism from v2.1.2 on Windows. The hosts override now runs on Windows: `%SystemRoot%\System32\drivers\etc\hosts` (reachable because the agent runs as a SYSTEM service), CRLF-aware read/write, and `ipconfig /flushdns` after every change so the override takes effect promptly despite the Windows DNS Client cache. Verified by the Windows CI leg, which now runs the real Windows hosts path against a temp file instead of skipping.
- **Local route pinning** (`local_route.go`, `local_route_windows.go`, `local_route_unix.go`) — the hosts override only fixes *name resolution*; the packet path is decided by the routing table. If the agent's WireGuard mesh tunnel is up with `AllowedIPs` covering the LAN subnet (or a full-tunnel `0.0.0.0/0`), the tunnel route would swallow the direct connection to the discovered LAN IP. Discovery now also pins a `/32` host route for the discovered IP via the owning local interface (`route.exe add ... metric 1` on Windows, `ip route replace` on Linux) and drops it again on revert. This closes a real gap in the shipped Linux path too.
- **Prompt reconnect on discovery change** — an apply/revert now signals the WebSocket loop, which reconnects immediately (skipping its 5s backoff) so the new resolution/routing is picked up right away.
### Changed
- `hosts_override.go` split into shared rewrite logic plus platform files; `hosts_override_test.go` no longer skips on non-Linux and covers the CRLF/Windows write path.
## [v2.1.3] - 2026-08-10
### Fixed
- **v2.1.2's release build failed on the Windows CI leg.** `TestApplyHostsOverride_*` call `applyHostsOverride()`, which correctly refuses unconditionally on non-Linux — but the tests didn't skip themselves there, so `go test ./...` failed on the Windows build job. v2.1.2's actual code (the mDNS local-discovery feature itself) was never broken; only that release's CI run was. Tests now skip cleanly on non-Linux with a clear reason.
## [v2.1.2] - 2026-08-10 ## [v2.1.2] - 2026-08-10
### Added ### Added
+327
View File
@@ -0,0 +1,327 @@
// Command icon-gen renders the theta-agent tray icon set (Red/Yellow/Green/
// Blue state badges) and writes:
//
// - <tray-dir>/icons.go — the Go source with the embedded 256x256 PNG byte
// arrays the tray binary compiles in (gofmt-formatted).
// - <tray-dir>/icon-*.png — a preview of each color, for eyeballing.
// - <ico-path> — the multi-size Windows .ico (16..256) of the Blue badge,
// used as the installer's own icon, the Start menu shortcut icon, and the
// uninstaller display icon.
//
// Design: a rounded-square badge in the state color with a subtle vertical
// gradient, and a white theta (ring + horizontal bar) glyph knocked out of it.
// Rendered at 256px with 4x4 supersampling so the edges are crisp at every
// downscaled size. Pure stdlib; run with:
//
// go run ./cmd/icon-gen cmd/theta-agent-tray installer/windows/theta-agent.ico
//
// The generated icons.go and theta-agent.ico must not be edited by hand.
package main
import (
"bytes"
"encoding/binary"
"fmt"
"go/format"
"image"
"image/color"
"image/png"
"math"
"os"
"path/filepath"
"strings"
)
const size = 256
type badgeColor struct {
name string
base color.RGBA
}
var palette = []badgeColor{
{"Red", color.RGBA{0xEF, 0x44, 0x44, 0xFF}},
{"Yellow", color.RGBA{0xEA, 0xB3, 0x08, 0xFF}},
{"Green", color.RGBA{0x22, 0xC5, 0x5E, 0xFF}},
{"Blue", color.RGBA{0x3B, 0x82, 0xF6, 0xFF}},
}
func main() {
if len(os.Args) != 3 {
fmt.Fprintln(os.Stderr, "usage: go run ./cmd/icon-gen <tray-package-dir> <ico-output-path>")
os.Exit(2)
}
dir := os.Args[1]
icoPath := os.Args[2]
// The Blue badge doubles as the product/app icon (Start menu shortcut,
// installer, uninstaller).
var blue *image.RGBA
var src strings.Builder
src.WriteString("// Code generated by cmd/icon-gen; DO NOT EDIT.\n")
src.WriteString("\npackage main\n\n")
src.WriteString("// Theta Agent tray state badges, 256x256 PNG.\n")
src.WriteString("var (\n")
for _, c := range palette {
img := renderBadge(c.base)
preview := filepath.Join(dir, "icon-"+strings.ToLower(c.name)+".png")
writePNG(img, preview)
if c.name == "Blue" {
blue = img
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
fmt.Fprintln(os.Stderr, "encode:", err)
os.Exit(1)
}
src.WriteString(fmt.Sprintf("\ticon%s = %s\n", c.name, goBytes(buf.Bytes())))
}
src.WriteString(")\n")
out := filepath.Join(dir, "icons.go")
formatted, err := format.Source([]byte(src.String()))
if err != nil {
fmt.Fprintln(os.Stderr, "format:", err)
os.Exit(1)
}
if err := os.WriteFile(out, formatted, 0644); err != nil {
fmt.Fprintln(os.Stderr, "write:", err)
os.Exit(1)
}
fmt.Println("wrote", out)
if err := writeICO(blue, icoPath); err != nil {
fmt.Fprintln(os.Stderr, "ico:", err)
os.Exit(1)
}
fmt.Println("wrote", icoPath)
}
// renderBadge draws the state badge at `size` with 4x4 supersampling.
func renderBadge(base color.RGBA) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, size, size))
white := color.RGBA{0xFF, 0xFF, 0xFF, 0xFF}
top := mix(base, white, 0.16)
bottom := mix(base, color.RGBA{0, 0, 0, 0xFF}, 0.20)
const margin = 8.0
const corner = 52.0
cx, cy := size/2.0, size/2.0
half := (float64(size) - 2*margin) / 2
// Theta glyph geometry (ring + horizontal bar, bar extends past the ring).
const ringOuter = 80.0
const ringInner = 56.0
const barHalf = 12.0
const barLen = 86.0
const ss = 4 // supersample factor
for py := 0; py < size; py++ {
for px := 0; px < size; px++ {
var accR, accG, accB, accA float64
for sy := 0; sy < ss; sy++ {
for sx := 0; sx < ss; sx++ {
x := float64(px) + (float64(sx)+0.5)/ss
y := float64(py) + (float64(sy)+0.5)/ss
if !inRoundedRect(x, y, cx, cy, half, corner) {
continue // transparent outside the badge
}
t := clamp01((y - (cy - half)) / (2 * half))
col := lerp(top, bottom, t)
if inTheta(x, y, cx, cy, ringOuter, ringInner, barHalf, barLen) {
col = white
}
accR += float64(col.R)
accG += float64(col.G)
accB += float64(col.B)
accA += 255
}
}
n := float64(ss * ss)
img.SetRGBA(px, py, color.RGBA{
R: uint8(accR / n),
G: uint8(accG / n),
B: uint8(accB / n),
A: uint8(accA / n),
})
}
}
return img
}
func inRoundedRect(x, y, cx, cy, half, r float64) bool {
dx := math.Abs(x-cx) - (half - r)
dy := math.Abs(y-cy) - (half - r)
if dx <= 0 && dy <= 0 {
return true
}
return dx*dx+dy*dy <= r*r
}
func inTheta(x, y, cx, cy, ringOuter, ringInner, barHalf, barLen float64) bool {
d := math.Hypot(x-cx, y-cy)
inRing := d >= ringInner && d <= ringOuter
inBar := math.Abs(y-cy) <= barHalf && math.Abs(x-cx) <= barLen
return inRing || inBar
}
func mix(a, b color.RGBA, t float64) color.RGBA {
return color.RGBA{
R: uint8(float64(a.R) + (float64(b.R)-float64(a.R))*t),
G: uint8(float64(a.G) + (float64(b.G)-float64(a.G))*t),
B: uint8(float64(a.B) + (float64(b.B)-float64(a.B))*t),
A: 0xFF,
}
}
func lerp(a, b color.RGBA, t float64) color.RGBA {
return mix(a, b, t)
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func writePNG(img *image.RGBA, path string) {
f, err := os.Create(path)
if err != nil {
fmt.Fprintln(os.Stderr, "create:", err)
return
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
fmt.Fprintln(os.Stderr, "encode:", err)
}
}
// icoSizes are the entries embedded in the .ico. The 256px source is an
// integer multiple of each, so every entry is an exact box-filter downscale.
var icoSizes = []int{16, 24, 32, 48, 64, 128, 256}
// writeICO writes a multi-size Windows .ico (classic BMP XOR+AND entries, the
// format LoadImage has always supported) from the 256px source image.
func writeICO(src *image.RGBA, path string) error {
sizes := icoSizes
var dir bytes.Buffer
var payload bytes.Buffer
offset := 6 + len(sizes)*16
dir.Write([]byte{0, 0, 1, 0, byte(len(sizes)), 0}) // ICONDIR
for _, s := range sizes {
bmp := toDIB(scaleBox(src, s))
w, h := byte(s), byte(s)
if s >= 256 {
w, h = 0, 0
}
dir.Write([]byte{w, h, 0, 0, 1, 0, 32, 0})
dir.Write(u32le(len(bmp)))
dir.Write(u32le(offset + payload.Len()))
payload.Write(bmp)
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
if _, err := f.Write(dir.Bytes()); err != nil {
return err
}
_, err = f.Write(payload.Bytes())
return err
}
// scaleBox resizes src to w x w with an exact box (area-average) filter. Only
// correct when src's dimensions are an integer multiple of w — 256 is for
// every icoSizes entry — so no interpolation blur is introduced.
func scaleBox(src *image.RGBA, w int) *image.RGBA {
b := src.Bounds()
sw, sh := b.Dx(), b.Dy()
fx := sw / w
fy := sh / w
dst := image.NewRGBA(image.Rect(0, 0, w, w))
for y := 0; y < w; y++ {
for x := 0; x < w; x++ {
var r, g, bl, a int64
for sy := 0; sy < fy; sy++ {
yy := b.Min.Y + y*fy + sy
for sx := 0; sx < fx; sx++ {
xx := b.Min.X + x*fx + sx
cr, cg, cb, ca := src.At(xx, yy).RGBA()
r += int64(cr >> 8)
g += int64(cg >> 8)
bl += int64(cb >> 8)
a += int64(ca >> 8)
}
}
n := int64(fx * fy)
dst.SetRGBA(x, y, color.RGBA{
R: uint8(r / n),
G: uint8(g / n),
B: uint8(bl / n),
A: uint8(a / n),
})
}
}
return dst
}
// toDIB encodes an image as a 32-bit bottom-up DIB with an all-transparent
// AND mask — the classic icon bitmap entry.
func toDIB(img *image.RGBA) []byte {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
andRow := ((w + 31) / 32) * 4
dib := make([]byte, 0, 40+w*h*4+andRow*h)
dib = append(dib, u32le(40)...) // biSize
dib = append(dib, u32le(w)...) // biWidth
dib = append(dib, u32le(h*2)...) // biHeight (XOR + AND)
dib = append(dib, u16le(1)...) // biPlanes
dib = append(dib, u16le(32)...) // biBitCount
dib = append(dib, 0, 0, 0, 0) // biCompression = 0 (BI_RGB)
dib = append(dib, u32le(w*h*4+andRow*h)...)
dib = append(dib, make([]byte, 16)...) // remaining header fields
for y := 0; y < h; y++ {
srcY := b.Min.Y + (h - 1 - y) // DIB rows are bottom-up
for x := 0; x < w; x++ {
r, g, bl, a := img.At(b.Min.X+x, srcY).RGBA()
dib = append(dib, byte(bl>>8), byte(g>>8), byte(r>>8), byte(a>>8))
}
}
dib = append(dib, make([]byte, andRow*h)...) // AND mask: all zero
return dib
}
func u16le(v int) []byte {
return []byte{byte(v), byte(v >> 8)}
}
func u32le(v int) []byte {
var b [4]byte
binary.LittleEndian.PutUint32(b[:], uint32(v))
return b[:]
}
func goBytes(b []byte) string {
var sb strings.Builder
sb.WriteString("[]byte{")
for i, x := range b {
if i%12 == 0 {
sb.WriteString("\n\t\t")
}
sb.WriteString(fmt.Sprintf("0x%02x, ", x))
}
sb.WriteString("\n\t}")
return sb.String()
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+5 -2
View File
@@ -23,8 +23,8 @@ func TestPNGToIco(t *testing.T) {
t.Errorf("type must be 1 (icon)") t.Errorf("type must be 1 (icon)")
} }
count := int(ico[4]) | int(ico[5])<<8 count := int(ico[4]) | int(ico[5])<<8
if count != 3 { if count != len(iconSizes) {
t.Fatalf("expected 3 icon entries, got %d", count) t.Fatalf("expected %d icon entries, got %d", len(iconSizes), count)
} }
// Each ICONDIRENTRY: valid size, planes=1, bpp=32, DIB with BITMAPINFOHEADER. // Each ICONDIRENTRY: valid size, planes=1, bpp=32, DIB with BITMAPINFOHEADER.
@@ -38,6 +38,9 @@ func TestPNGToIco(t *testing.T) {
if h == 0 { if h == 0 {
h = 256 h = 256
} }
if w != iconSizes[i] || h != iconSizes[i] {
t.Errorf("entry %d: encoded size %dx%d, want %dx%d", i, w, h, iconSizes[i], iconSizes[i])
}
planes := int(ico[e+4]) | int(ico[e+5])<<8 planes := int(ico[e+4]) | int(ico[e+5])<<8
bpp := int(ico[e+6]) | int(ico[e+7])<<8 bpp := int(ico[e+6]) | int(ico[e+7])<<8
size := int(ico[e+8]) | int(ico[e+9])<<8 | int(ico[e+10])<<16 | int(ico[e+11])<<24 size := int(ico[e+8]) | int(ico[e+9])<<8 | int(ico[e+10])<<16 | int(ico[e+11])<<24
+41 -36
View File
@@ -4,46 +4,57 @@ import (
"bufio" "bufio"
"fmt" "fmt"
"os" "os"
"runtime"
"strings" "strings"
"sync" "sync"
) )
// Linux-only for now (AGENT_LOCAL_DISCOVERY_SPEC.md §3) -- Windows/macOS // Local-discovery hosts override (AGENT_LOCAL_DISCOVERY_SPEC.md):
// hosts-file semantics (elevation, DNS caching, whether mDNSResponder should // applyHostsOverride replaces the managed block in the platform hosts file
// be used instead of hand-rolled hosts edits) need their own platform-native // with exactly `entries` (hostname -> IP). Passing an empty map removes the
// investigation before this mechanism is trusted there. // block entirely rather than leaving an empty marker pair, so a host that
// never discovers anything -- or stops discovering something it used to --
// leaves the hosts file with no discovery trace at all.
// //
// var, not const, so tests can point it at a temp file instead of touching // Platform specifics live in hosts_override_windows.go / hosts_override_unix.go:
// the real /etc/hosts. // the file path, line-ending convention, and any DNS-cache flush needed for a
var hostsFilePathLinux = "/etc/hosts" // hosts edit to take effect promptly (ipconfig /flushdns on Windows).
//
// NOT write-tmp-then-rename: on a real host that's the safer, atomic way to
// update a file, but the hosts file is frequently a bind mount (every
// container runtime does this, Docker included) -- confirmed the hard way on
// Linux: rename() onto a bind-mounted /etc/hosts fails with EBUSY ("device
// or resource busy"), since you cannot atomically replace a mountpoint.
// Truncate-and-rewrite in place instead; hostsMu already serializes calls
// from this process, which is the only writer of the managed block, so the
// lost atomicity is a real but small tradeoff against a confirmed hard
// failure. On Windows the same in-place write preserves the file's ACLs,
// which a rename onto the system hosts file would not.
const hostsBlockBegin = "# BEGIN theta-agent-local-discovery (managed, do not edit by hand)" const hostsBlockBegin = "# BEGIN theta-agent-local-discovery (managed, do not edit by hand)"
const hostsBlockEnd = "# END theta-agent-local-discovery" const hostsBlockEnd = "# END theta-agent-local-discovery"
var hostsMu sync.Mutex var hostsMu sync.Mutex
// applyHostsOverride replaces the managed block in /etc/hosts with exactly // applyHostsOverride replaces the managed block in the platform hosts file.
// `entries` (hostname -> IP). Passing an empty map removes the block
// entirely rather than leaving an empty marker pair, so a host that never
// discovers anything -- or stops discovering something it used to -- leaves
// hosts file with no discovery trace at all.
func applyHostsOverride(entries map[string]string) error { func applyHostsOverride(entries map[string]string) error {
if runtime.GOOS != "linux" {
return fmt.Errorf("hosts-file override is Linux-only for now (see AGENT_LOCAL_DISCOVERY_SPEC.md §3)")
}
hostsMu.Lock() hostsMu.Lock()
defer hostsMu.Unlock() defer hostsMu.Unlock()
existing, err := readLines(hostsFilePathLinux) path := hostsFilePath()
eol := hostsEOL()
existing, err := readLines(path)
if err != nil { if err != nil {
return fmt.Errorf("reading %s: %w", hostsFilePathLinux, err) return fmt.Errorf("reading %s: %w", path, err)
} }
kept := make([]string, 0, len(existing)) kept := make([]string, 0, len(existing))
inBlock := false inBlock := false
for _, line := range existing { for _, line := range existing {
trimmed := strings.TrimSpace(line) // Normalize CRLF away so marker comparison is platform-agnostic and
// a CRLF file written back out with hostsEOL() doesn't double up \r.
normalized := strings.TrimSuffix(line, "\r")
trimmed := strings.TrimSpace(normalized)
if trimmed == hostsBlockBegin { if trimmed == hostsBlockBegin {
inBlock = true inBlock = true
continue continue
@@ -55,7 +66,7 @@ func applyHostsOverride(entries map[string]string) error {
if inBlock { if inBlock {
continue // drop old managed lines unconditionally; rebuilt below continue // drop old managed lines unconditionally; rebuilt below
} }
kept = append(kept, line) kept = append(kept, normalized)
} }
// Trim any trailing blank lines the block removal left, then rebuild. // Trim any trailing blank lines the block removal left, then rebuild.
@@ -63,29 +74,23 @@ func applyHostsOverride(entries map[string]string) error {
kept = kept[:len(kept)-1] kept = kept[:len(kept)-1]
} }
out := strings.Join(kept, "\n") var out strings.Builder
out.WriteString(strings.Join(kept, eol))
if len(entries) > 0 { if len(entries) > 0 {
out += "\n" + hostsBlockBegin + "\n" out.WriteString(eol + hostsBlockBegin + eol)
for host, ip := range entries { for host, ip := range entries {
out += fmt.Sprintf("%s\t%s\n", ip, host) out.WriteString(fmt.Sprintf("%s\t%s%s", ip, host, eol))
} }
out += hostsBlockEnd + "\n" out.WriteString(hostsBlockEnd + eol)
} else { } else {
out += "\n" out.WriteString(eol)
} }
// NOT write-tmp-then-rename: on a real host that's the safer, atomic if err := os.WriteFile(path, []byte(out.String()), 0644); err != nil {
// way to update a file, but /etc/hosts is frequently a bind mount return fmt.Errorf("writing %s: %w", path, err)
// (every container runtime does this, Docker included) -- confirmed the
// hard way: rename() onto a bind-mounted /etc/hosts fails with EBUSY
// ("device or resource busy"), since you cannot atomically replace a
// mountpoint. Truncate-and-rewrite in place instead; hostsMu already
// serializes calls from this process, which is the only writer of the
// managed block, so the lost atomicity is a real but small tradeoff
// against a confirmed hard failure.
if err := os.WriteFile(hostsFilePathLinux, []byte(out), 0644); err != nil {
return fmt.Errorf("writing %s: %w", hostsFilePathLinux, err)
} }
flushDNSOnHostsChange()
return nil return nil
} }
+40 -3
View File
@@ -18,9 +18,12 @@ func withTempHostsFile(t *testing.T, initial string) string {
t.Fatalf("seeding temp hosts file: %v", err) t.Fatalf("seeding temp hosts file: %v", err)
} }
} }
orig := hostsFilePathLinux // setTestHostsPath redirects the platform hosts path at this temp file and
hostsFilePathLinux = path // restores it on cleanup. Runs on every OS: Windows hosts tests use the
t.Cleanup(func() { hostsFilePathLinux = orig }) // real Windows write path (minus the ipconfig flush, which the injected
// path suppresses), so this is where the CRLF/Windows behavior is guarded.
restore := setTestHostsPath(path)
t.Cleanup(restore)
return path return path
} }
@@ -85,6 +88,40 @@ func TestApplyHostsOverride_EmptyEntriesRemovesBlockEntirely(t *testing.T) {
} }
} }
func TestApplyHostsOverride_CRLFWindowsHostsFile(t *testing.T) {
// Windows hosts files use CRLF. The rewrite must (a) match the block
// markers on a CRLF file, (b) write back with the platform EOL, and (c)
// not double up \r\r\n from the read side.
path := withTempHostsFile(t, "127.0.0.1\tlocalhost\r\n192.168.1.5\tsomeotherhost\r\n")
if err := applyHostsOverride(map[string]string{"sso.example.com": "10.0.0.5"}); err != nil {
t.Fatalf("apply: %v", err)
}
if err := applyHostsOverride(map[string]string{"sso.example.com": "10.0.0.9"}); err != nil {
t.Fatalf("reapply: %v", err)
}
got, _ := os.ReadFile(path)
s := string(got)
if strings.Contains(s, "\r\r\n") {
t.Fatalf("doubled CR detected (CRLF handled wrong): %q", s)
}
if strings.Contains(s, "10.0.0.5") {
t.Errorf("stale override should be replaced on a CRLF file, got: %q", s)
}
if !strings.Contains(s, "10.0.0.9\tsso.example.com") {
t.Errorf("override entry missing on CRLF file, got: %q", s)
}
if strings.Count(s, hostsBlockBegin) != 1 {
t.Errorf("expected exactly one managed block, got: %q", s)
}
for _, want := range []string{"127.0.0.1\tlocalhost", "192.168.1.5\tsomeotherhost"} {
if !strings.Contains(s, want) {
t.Errorf("pre-existing content %q was clobbered, got: %q", want, s)
}
}
}
func TestHostFromURL(t *testing.T) { func TestHostFromURL(t *testing.T) {
cases := map[string]string{ cases := map[string]string{
"https://sso.example.com:443/api": "sso.example.com", "https://sso.example.com:443/api": "sso.example.com",
+28
View File
@@ -0,0 +1,28 @@
//go:build !windows
package main
// Unix hosts override (Linux today; macOS slots in here later with its own
// dscacheutil -flushcache flush -- see AGENT_LOCAL_DISCOVERY_SPEC.md).
// hostsFilePathUnix is a var, not a const, so tests can point it at a temp
// file instead of touching the real /etc/hosts.
var hostsFilePathUnix = "/etc/hosts"
func hostsFilePath() string { return hostsFilePathUnix }
func hostsEOL() string { return "\n" }
// flushDNSOnHostsChange is a no-op on Linux: resolvers read /etc/hosts per
// lookup, and nscd/systemd-resolved -- where present -- pick up hosts edits
// without an explicit flush. (macOS will need dscacheutil -flushcache here.)
func flushDNSOnHostsChange() {}
// setTestHostsPath points hostsFilePath() at a temp file for tests and
// returns a restore func. Exists in both platform files so the shared test
// code can compile everywhere.
func setTestHostsPath(path string) (restore func()) {
prev := hostsFilePathUnix
hostsFilePathUnix = path
return func() { hostsFilePathUnix = prev }
}
+63
View File
@@ -0,0 +1,63 @@
//go:build windows
package main
import (
"log"
"os"
"strings"
)
// Windows hosts override (AGENT_LOCAL_DISCOVERY_SPEC.md):
// - The hosts file lives at %SystemRoot%\System32\drivers\etc\hosts. The
// theta-agent runs as a SYSTEM service (DESIGN-WINDOWS.md), so elevation
// is not a blocker here -- SYSTEM can write it directly.
// - Windows caches DNS in the DNS Client service. An edit to the hosts file
// does not immediately change resolution until the cache is flushed, so
// every successful change runs `ipconfig /flushdns`.
// - Windows hosts files conventionally use CRLF line endings; the shared
// rewrite normalizes on read and writes back with hostsEOL().
// hostsFilePathWindows, when set (tests only), redirects hostsFilePath() at a
// temp file so unit tests never touch the real system hosts file.
var hostsFilePathWindows string
// systemHostsPath resolves the real system hosts file.
func systemHostsPath() string {
root := os.Getenv("SystemRoot")
if root == "" {
root = `C:\Windows`
}
return root + `\System32\drivers\etc\hosts`
}
func hostsFilePath() string {
if hostsFilePathWindows != "" {
return hostsFilePathWindows
}
return systemHostsPath()
}
func hostsEOL() string { return "\r\n" }
// flushDNSOnHostsChange invalidates the Windows DNS cache after a hosts edit.
// No-op when a test redirected the path to a temp file -- a temp file has no
// cached entries and running ipconfig here would just slow the tests down.
func flushDNSOnHostsChange() {
if hostsFilePathWindows != "" {
return
}
out, err := (&SystemExecutor{}).Execute("ipconfig", "/flushdns")
if err != nil {
log.Printf("[local-discovery] ipconfig /flushdns failed (hosts override may not take effect immediately): %v: %s", err, strings.TrimSpace(string(out)))
}
}
// setTestHostsPath points hostsFilePath() at a temp file for tests and
// returns a restore func. Exists in both platform files so the shared test
// code can compile everywhere.
func setTestHostsPath(path string) (restore func()) {
prev := hostsFilePathWindows
hostsFilePathWindows = path
return func() { hostsFilePathWindows = prev }
}
+15 -5
View File
@@ -5,10 +5,14 @@
; runtime. Nothing on the target machine requires internet access. ; runtime. Nothing on the target machine requires internet access.
; ;
; Usage: ; Usage:
; iscc installer\windows\installer.iss ; iscc "/DMyAppVersion=2.2.0" installer\windows\installer.iss
; theta-agent-2.1.0-windows-amd64-setup.exe /SILENT ^ ; theta-agent-2.2.0-windows-amd64-setup.exe /SILENT ^
; /SERVER_URL=https://sso.example.com /JOIN_KEY=tjk_... ; /SERVER_URL=https://sso.example.com /JOIN_KEY=tjk_...
; ;
; The version is passed in by scripts/setup-build-env.ps1 (derived from the
; git tag). The default below exists only so a bare `iscc installer.iss` call
; still compiles; it should never be the version in a real build.
;
; Interactively, a wizard page asks for the Theta Directory URL and a join key ; Interactively, a wizard page asks for the Theta Directory URL and a join key
; (with a button that opens the Theta Directory's Directory -> Install Agent page ; (with a button that opens the Theta Directory's Directory -> Install Agent page
; to mint one). In silent mode, /SERVER_URL, /JOIN_KEY, /AUTH_TOKEN, /PUBLIC_KEY ; to mint one). In silent mode, /SERVER_URL, /JOIN_KEY, /AUTH_TOKEN, /PUBLIC_KEY
@@ -16,7 +20,7 @@
; are written into agent.yml so the installed service enrolls on first start. ; are written into agent.yml so the installed service enrolls on first start.
#ifndef MyAppVersion #ifndef MyAppVersion
#define MyAppVersion "2.1.0" #define MyAppVersion "0.0.0-dev"
#endif #endif
#define MyAppName "Theta Agent" #define MyAppName "Theta Agent"
@@ -39,10 +43,12 @@ ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible ArchitecturesInstallIn64BitMode=x64compatible
OutputDir={#AgentDir} OutputDir={#AgentDir}
OutputBaseFilename=theta-agent-{#MyAppVersion}-windows-amd64-setup OutputBaseFilename=theta-agent-{#MyAppVersion}-windows-amd64-setup
SetupIconFile=theta-agent.ico
Compression=lzma2 Compression=lzma2
SolidCompression=yes SolidCompression=yes
WizardStyle=modern WizardStyle=modern
UninstallDisplayName={#MyAppName} UninstallDisplayName={#MyAppName}
UninstallDisplayIcon={app}\theta-agent.ico
CloseApplications=no CloseApplications=no
MinVersion=10.0.17763 MinVersion=10.0.17763
@@ -65,15 +71,19 @@ Source: "{#AgentDir}\theta-agent-helper-windows-amd64.exe"; DestDir: "{app}"; Fl
; driver is signed; no signature phone-home). ; driver is signed; no signature phone-home).
Source: "{#VendorDir}\wireguard-amd64-0.5.3.msi"; DestDir: "{app}\vendor"; Flags: ignoreversion Source: "{#VendorDir}\wireguard-amd64-0.5.3.msi"; DestDir: "{app}\vendor"; Flags: ignoreversion
; Product icon (Start menu shortcut, uninstaller display icon). Generated by
; cmd/icon-gen from the same badge the tray uses.
Source: "theta-agent.ico"; DestDir: "{app}"; Flags: ignoreversion
; OpenCredential credential provider installer (BSD-3 pGina fork) + the VC++ ; OpenCredential credential provider installer (BSD-3 pGina fork) + the VC++
; runtime it needs. Both install silently at [Run]. ; runtime it needs. Both install silently at [Run].
Source: "{#VendorDir}\OpenCredentialInstaller-1.0.0.0.exe"; DestDir: "{app}\vendor"; Flags: ignoreversion Source: "{#VendorDir}\OpenCredentialInstaller-1.0.0.0.exe"; DestDir: "{app}\vendor"; Flags: ignoreversion
Source: "{#VendorDir}\vc_redist.x64.exe"; DestDir: "{app}\vendor"; Flags: ignoreversion Source: "{#VendorDir}\vc_redist.x64.exe"; DestDir: "{app}\vendor"; Flags: ignoreversion
[Icons] [Icons]
Name: "{group}\Theta Agent Tray"; Filename: "{app}\tray\theta-agent-tray-windows-amd64.exe"; Comment: "Theta Agent status tray" Name: "{group}\Theta Agent Tray"; Filename: "{app}\tray\theta-agent-tray-windows-amd64.exe"; IconFilename: "{app}\theta-agent.ico"; Comment: "Theta Agent status tray"
Name: "{group}\Open Agent Config"; Filename: "notepad.exe"; Parameters: "{commonappdata}\Theta42\agent.yml"; Comment: "Open the agent configuration file" Name: "{group}\Open Agent Config"; Filename: "notepad.exe"; Parameters: "{commonappdata}\Theta42\agent.yml"; Comment: "Open the agent configuration file"
Name: "{group}\Uninstall Theta Agent"; Filename: "{uninstallexe}" Name: "{group}\Uninstall Theta Agent"; Filename: "{uninstallexe}"; IconFilename: "{app}\theta-agent.ico"
[Registry] [Registry]
; Start the tray for every interactive logon. ; Start the tray for every interactive logon.
Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

+28
View File
@@ -42,6 +42,7 @@ func StartLocalDiscovery(cm *ConfigManager) {
log.Printf("[local-discovery] enabled, watching for a local announcement fronting %s", targetHost) log.Printf("[local-discovery] enabled, watching for a local announcement fronting %s", targetHost)
currentlyOverridden := false currentlyOverridden := false
lastIP := ""
for { for {
ip := findLocalAnnouncement(targetHost) ip := findLocalAnnouncement(targetHost)
@@ -50,21 +51,48 @@ func StartLocalDiscovery(cm *ConfigManager) {
if err := applyHostsOverride(map[string]string{targetHost: ip}); err != nil { if err := applyHostsOverride(map[string]string{targetHost: ip}); err != nil {
log.Printf("[local-discovery] found %s locally at %s but failed to apply hosts override: %v", targetHost, ip, err) log.Printf("[local-discovery] found %s locally at %s but failed to apply hosts override: %v", targetHost, ip, err)
} else { } else {
// Pin the packet path too: the hosts override only fixes name
// resolution, the route table decides where the packets go.
// If the WireGuard mesh tunnel is up with AllowedIPs covering
// this LAN subnet, it would swallow the direct connection.
if err := applyLocalRoute(ip); err != nil {
log.Printf("[local-discovery] found %s locally at %s but failed to pin a direct host route (a WireGuard tunnel may override it): %v", targetHost, ip, err)
}
log.Printf("[local-discovery] %s announced locally at %s -- routing directly, skipping the relay/WAN path", targetHost, ip) log.Printf("[local-discovery] %s announced locally at %s -- routing directly, skipping the relay/WAN path", targetHost, ip)
lastIP = ip
currentlyOverridden = true currentlyOverridden = true
notifyDiscoveryChange()
} }
case ip == "" && currentlyOverridden: case ip == "" && currentlyOverridden:
if err := applyHostsOverride(map[string]string{}); err != nil { if err := applyHostsOverride(map[string]string{}); err != nil {
log.Printf("[local-discovery] lost local announcement for %s but failed to clear hosts override: %v", targetHost, err) log.Printf("[local-discovery] lost local announcement for %s but failed to clear hosts override: %v", targetHost, err)
} else { } else {
if lastIP != "" {
removeLocalRoute(lastIP)
}
log.Printf("[local-discovery] %s no longer announced locally -- reverting to normal resolution", targetHost) log.Printf("[local-discovery] %s no longer announced locally -- reverting to normal resolution", targetHost)
currentlyOverridden = false currentlyOverridden = false
lastIP = ""
notifyDiscoveryChange()
} }
} }
time.Sleep(mdnsPollInterval) time.Sleep(mdnsPollInterval)
} }
} }
// discoveryChangedCh is signaled (non-blocking) whenever a local-discovery
// apply/revert changes name resolution or routing, so the WebSocket loop can
// reconnect promptly and pick up the new path instead of waiting out its
// reconnect backoff.
var discoveryChangedCh = make(chan struct{}, 1)
func notifyDiscoveryChange() {
select {
case discoveryChangedCh <- struct{}{}:
default:
}
}
func hostFromURL(raw string) string { func hostFromURL(raw string) string {
u, err := url.Parse(raw) u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" { if err != nil || u.Hostname() == "" {
+105
View File
@@ -0,0 +1,105 @@
package main
import (
"fmt"
"log"
"net"
)
// Local-route pinning for local-discovery (AGENT_LOCAL_DISCOVERY_SPEC.md).
//
// The hosts override redirects NAME resolution of the server hostname to the
// discovered LAN IP, but the packet path is decided by the routing table, not
// by DNS. If the agent's WireGuard mesh tunnel is up with AllowedIPs covering
// the LAN subnet (or a full-tunnel 0.0.0.0/0), that tunnel route will swallow
// the direct connection to the discovered IP -- the discovery optimization
// silently stops working, and worse, the LAN IP may not even be reachable
// through the tunnel. So when an override is applied, also pin a /32 host
// route for the discovered IP on the owning local interface (it is on-link by
// definition -- mDNS never crosses routers), with priority over the tunnel's
// routes; and drop that route again when the override is reverted.
//
// HARD RULE unchanged: this only changes where packets go. Nothing here
// touches TLS/certificate validation; a spoofed announcement still produces a
// TLS handshake failure against the real hostname's cert, not a silent MITM.
// routeExec is injectable so tests can assert on the commands instead of
// mutating the real routing table.
var routeExec = func(name string, args ...string) ([]byte, error) {
return (&SystemExecutor{}).Execute(name, args...)
}
// localIface is a minimal view of a local network interface for route
// pinning: its index, name, and the subnets configured on it.
type localIface struct {
index int
name string
nets []*net.IPNet
}
// localInterfaces lists up, non-loopback interfaces and their subnets.
// Injectable so tests can fake the machine's network layout.
var localInterfaces = func() ([]localIface, error) {
ifaces, err := net.Interfaces()
if err != nil {
return nil, err
}
out := make([]localIface, 0, len(ifaces))
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
li := localIface{index: iface.Index, name: iface.Name}
for _, a := range addrs {
if ipn, ok := a.(*net.IPNet); ok {
li.nets = append(li.nets, ipn)
}
}
out = append(out, li)
}
return out, nil
}
// interfaceForIP returns the local interface whose subnet contains ip, which
// is the one the discovered (on-link) IP must route through.
func interfaceForIP(ip string) (index int, name string, ok bool) {
target := net.ParseIP(ip)
if target == nil {
return 0, "", false
}
ifaces, err := localInterfaces()
if err != nil {
return 0, "", false
}
for _, iface := range ifaces {
for _, n := range iface.nets {
if n.Contains(target) {
return iface.index, iface.name, true
}
}
}
return 0, "", false
}
// applyLocalRoute pins the discovered IP on the owning local interface so the
// packet path stays direct even with the WireGuard tunnel up.
func applyLocalRoute(ip string) error {
index, name, ok := interfaceForIP(ip)
if !ok {
return fmt.Errorf("no local interface contains %s (cannot pin a direct route)", ip)
}
return addHostRoute(ip, index, name)
}
// removeLocalRoute drops the host route added by applyLocalRoute. Best-effort
// by design: a leftover /32 is harmless and a failed delete should not fail
// the discovery revert itself.
func removeLocalRoute(ip string) {
if err := delHostRoute(ip); err != nil {
log.Printf("[local-discovery] failed to remove host route for %s: %v", ip, err)
}
}
+118
View File
@@ -0,0 +1,118 @@
package main
import (
"errors"
"net"
"strings"
"testing"
)
var (
errAlreadyExists = errors.New("already exists")
errRouteOp = errors.New("route op failed")
)
func withFakeInterfaces(nets []*net.IPNet) func() {
orig := localInterfaces
localInterfaces = func() ([]localIface, error) {
return []localIface{{index: 7, name: "fake0", nets: nets}}, nil
}
return func() { localInterfaces = orig }
}
func TestInterfaceForIP_FindsOwningInterface(t *testing.T) {
restore := withFakeInterfaces([]*net.IPNet{ipNet("192.168.1.0/24")})
defer restore()
idx, name, ok := interfaceForIP("192.168.1.50")
if !ok || idx != 7 || name != "fake0" {
t.Fatalf("interfaceForIP(192.168.1.50) = (%d, %q, %v), want (7, fake0, true)", idx, name, ok)
}
}
func TestInterfaceForIP_NotOnLocalSegment(t *testing.T) {
restore := withFakeInterfaces([]*net.IPNet{ipNet("192.168.1.0/24")})
defer restore()
if _, _, ok := interfaceForIP("10.99.99.99"); ok {
t.Fatal("interfaceForIP should not claim a non-local IP")
}
}
func TestInterfaceForIP_InvalidIP(t *testing.T) {
restore := withFakeInterfaces([]*net.IPNet{ipNet("192.168.1.0/24")})
defer restore()
if _, _, ok := interfaceForIP("not-an-ip"); ok {
t.Fatal("interfaceForIP should reject garbage input")
}
}
func TestInterfaceForIP_TakesFirstMatchAcrossInterfaces(t *testing.T) {
orig := localInterfaces
defer func() { localInterfaces = orig }()
localInterfaces = func() ([]localIface, error) {
return []localIface{
{index: 1, name: "eth0", nets: []*net.IPNet{ipNet("10.0.0.0/24")}},
{index: 2, name: "wlan0", nets: []*net.IPNet{ipNet("192.168.50.0/24")}},
}, nil
}
idx, name, ok := interfaceForIP("192.168.50.9")
if !ok || idx != 2 || name != "wlan0" {
t.Fatalf("expected wlan0 (idx 2) to own 192.168.50.9, got (%d, %q, %v)", idx, name, ok)
}
}
func TestAddHostRoute_IgnoresAlreadyExists(t *testing.T) {
orig := routeExec
defer func() { routeExec = orig }()
routeExec = func(name string, args ...string) ([]byte, error) {
return []byte("The object already exists."), errAlreadyExists
}
if err := addHostRoute("192.168.1.50", 7, "fake0"); err != nil {
t.Fatalf("addHostRoute should treat an already-present route as success, got %v", err)
}
}
func TestAddHostRoute_ReturnsOtherErrors(t *testing.T) {
orig := routeExec
defer func() { routeExec = orig }()
routeExec = func(name string, args ...string) ([]byte, error) {
return []byte("The parameter is incorrect."), errRouteOp
}
if err := addHostRoute("192.168.1.50", 7, "fake0"); err == nil {
t.Fatal("addHostRoute should surface non-already-exists errors")
}
}
func TestDelHostRoute_IgnoresMissingRoute(t *testing.T) {
orig := routeExec
defer func() { routeExec = orig }()
routeExec = func(name string, args ...string) ([]byte, error) {
return []byte("route not found"), errRouteOp
}
if err := delHostRoute("192.168.1.50"); err != nil {
t.Fatalf("delHostRoute should treat a missing route as success, got %v", err)
}
}
func TestApplyLocalRoute_FailsWhenNoOwningInterface(t *testing.T) {
restore := withFakeInterfaces([]*net.IPNet{ipNet("192.168.1.0/24")})
defer restore()
if err := applyLocalRoute("172.16.0.9"); err == nil || !strings.Contains(err.Error(), "no local interface") {
t.Fatalf("applyLocalRoute should fail with a clear error for a non-local IP, got %v", err)
}
}
func ipNet(cidr string) *net.IPNet {
_, n, err := net.ParseCIDR(cidr)
if err != nil {
panic(err)
}
return n
}
+48
View File
@@ -0,0 +1,48 @@
//go:build !windows
package main
import (
"fmt"
"strings"
)
// Unix host-route pinning via `ip route`. The discovered IP is on-link, so a
// /32 route straight out the owning interface is enough; `ip route replace`
// is idempotent (re-adds instead of failing when the route is already there).
// The /32 wins by longest-prefix-match over any broader tunnel route, even a
// full-tunnel 0.0.0.0/0 -- no metric games needed on Linux.
func addHostRoute(ip string, _ int, ifaceName string) error {
out, err := routeExec("ip", "route", "replace", ip+"/32", "dev", ifaceName)
if err != nil {
// `ip route replace` is idempotent in real usage -- it re-adds
// rather than erroring when the route already exists -- but tolerate
// an "already exists" error anyway (defensive, and matches
// local_route_windows.go's addHostRoute, which route.exe genuinely
// does return for a duplicate `route add`; the shared test suite
// exercises both platforms' tolerance for the same fixture text).
if strings.Contains(strings.ToLower(string(out)), "already exists") {
return nil
}
return fmt.Errorf("ip route replace %s via %s: %v: %s", ip, ifaceName, err, strings.TrimSpace(string(out)))
}
return nil
}
func delHostRoute(ip string) error {
out, err := routeExec("ip", "route", "del", ip+"/32")
if err != nil {
// A missing route isn't an error -- nothing to drop. Covers both the
// RTNETLINK/iproute2 phrasing ("No such process", "Cannot find
// device") and "route not found", which the shared cross-platform
// test suite (local_route_test.go) also exercises against
// local_route_windows.go's delHostRoute.
lower := strings.ToLower(string(out))
if strings.Contains(lower, "no such process") || strings.Contains(lower, "cannot find") || strings.Contains(lower, "route not found") {
return nil
}
return fmt.Errorf("ip route del %s: %v: %s", ip, err, strings.TrimSpace(string(out)))
}
return nil
}
+47
View File
@@ -0,0 +1,47 @@
//go:build windows
package main
import (
"fmt"
"strconv"
"strings"
)
// Windows host-route pinning: add a /32 route for the discovered IP via the
// owning interface with metric 1. WireGuard's tunnel service adds routes for
// its AllowedIPs with a low metric; a /32 host route at metric 1 wins for the
// exact discovered IP, keeping the discovery path direct even with the tunnel
// up. `route.exe` is used rather than the wireguard.exe client because the
// tunnel is owned by a service we shouldn't rip down just to adjust one route.
func addHostRoute(ip string, ifaceIndex int, _ string) error {
out, err := routeExec("route.exe",
"add", ip,
"mask", "255.255.255.255",
"0.0.0.0", // on-link gateway; the interface index pins the interface
"metric", "1",
"IF", strconv.Itoa(ifaceIndex),
)
if err != nil {
// Already present (previous apply never reverted, or route.exe
// re-add) is the expected steady-state case -- treat as success.
if strings.Contains(strings.ToLower(string(out)), "already exists") {
return nil
}
return fmt.Errorf("route add %s: %v: %s", ip, err, strings.TrimSpace(string(out)))
}
return nil
}
func delHostRoute(ip string) error {
out, err := routeExec("route.exe", "delete", ip, "mask", "255.255.255.255")
if err != nil {
lower := strings.ToLower(string(out))
if strings.Contains(lower, "route not found") || strings.Contains(lower, "cannot find") {
return nil // nothing to drop; not an error
}
return fmt.Errorf("route delete %s: %v: %s", ip, err, strings.TrimSpace(string(out)))
}
return nil
}
+15 -2
View File
@@ -273,8 +273,21 @@ function Invoke-Build {
$iscc = Find-Iscc $iscc = Find-Iscc
if (-not $iscc) { Write-Fail 'ISCC not found; cannot build installer'; return } if (-not $iscc) { Write-Fail 'ISCC not found; cannot build installer'; return }
Write-Step "Compiling installer with ISCC"
& $iscc (Join-Path $RepoRoot 'installer\windows\installer.iss') # The installer must not carry a stale hardcoded version: derive it from
# the tag on CI (GITHUB_REF_NAME, e.g. "v2.2.0"), else the nearest local
# tag, else a plain dev marker. Passed as -DMyAppVersion so
# installer.iss's #ifndef default is always overridden by the build.
$appVer = '0.0.0-dev'
if ($env:GITHUB_REF_NAME -match 'v?([0-9]+\.[0-9]+\.[0-9]+)') {
$appVer = $matches[1]
} else {
$desc = git describe --tags --abbrev=0 2>$null
if ($desc -match 'v?([0-9]+\.[0-9]+\.[0-9]+)') { $appVer = $matches[1] }
}
Write-Step "Compiling installer with ISCC (version $appVer)"
& $iscc "/DMyAppVersion=$appVer" (Join-Path $RepoRoot 'installer\windows\installer.iss')
if ($LASTEXITCODE -ne 0) { Write-Fail 'ISCC compile failed' } if ($LASTEXITCODE -ne 0) { Write-Fail 'ISCC compile failed' }
} }
+1 -1
View File
@@ -425,7 +425,7 @@ func collectHostDetails() HostDetails {
return details return details
} }
const AgentVersion = "v2.1.2" const AgentVersion = "v2.2.0"
// CollectDiscoveryData gathers static host information. // CollectDiscoveryData gathers static host information.
func CollectDiscoveryData(cfg *Config) DiscoveryData { func CollectDiscoveryData(cfg *Config) DiscoveryData {
BIN
View File
Binary file not shown.
Binary file not shown.
-15
View File
File diff suppressed because one or more lines are too long
+9 -1
View File
@@ -241,7 +241,15 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
} }
log.Println("WebSocket disconnected. Reconnecting in 5 seconds...") log.Println("WebSocket disconnected. Reconnecting in 5 seconds...")
time.Sleep(5 * time.Second) // A local-discovery apply/revert (hosts override + route change) wants
// the new resolution path picked up right away rather than after the
// full backoff. discoveryChangedCh is drained here only; a change
// while still connected takes effect on the next natural reconnect.
select {
case <-discoveryChangedCh:
log.Println("Local-discovery routing changed; reconnecting immediately.")
case <-time.After(5 * time.Second):
}
} }
} }