Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef9d4b004b | |||
| 4074f108c1 | |||
| 12d55a4454 | |||
| 1b332cacab | |||
| 52eba72613 | |||
| c7d599de85 | |||
| 230b7fb172 | |||
| b2ad8f4844 |
@@ -5,6 +5,23 @@ 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
|
## [v2.1.3] - 2026-08-10
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
-12
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -12,14 +11,6 @@ import (
|
|||||||
|
|
||||||
func withTempHostsFile(t *testing.T, initial string) string {
|
func withTempHostsFile(t *testing.T, initial string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
// applyHostsOverride refuses unconditionally on non-Linux (see
|
|
||||||
// hosts_override.go) -- these tests exercise the Linux write path
|
|
||||||
// specifically, so they'd fail for the right reason on the Windows CI
|
|
||||||
// runner if not skipped. Confirmed the hard way: a real CI run failed
|
|
||||||
// here after this was missed.
|
|
||||||
if runtime.GOOS != "linux" {
|
|
||||||
t.Skip("applyHostsOverride is Linux-only; skipping on " + runtime.GOOS)
|
|
||||||
}
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "hosts")
|
path := filepath.Join(dir, "hosts")
|
||||||
if initial != "" {
|
if initial != "" {
|
||||||
@@ -27,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,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",
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -425,7 +425,7 @@ func collectHostDetails() HostDetails {
|
|||||||
return details
|
return details
|
||||||
}
|
}
|
||||||
|
|
||||||
const AgentVersion = "v2.1.3"
|
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
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
+9
-1
@@ -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):
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user