Scaffold ipswap: tray-driven Windows IP preset switcher
Implements the design in claude.md as a building skeleton: pure Go, no cgo, cross-compiles to a single Windows .exe from Linux. Architecture follows the spec's deliberate split. The fast path is native — tray icon, grouped submenus, a Win32 MessageBox showing a live before/after diff, then netsh. The slow path is an embedded web editor served on a random loopback port and opened in the default browser. Two decisions worth recording: Reads use GetAdaptersAddresses, writes use netsh. The spec left the enumeration mechanism open; parsing `netsh show config` breaks on a non-English Windows because the output is localised. DNS static-vs-DHCP origin is not exposed by that API, so it comes from one registry read. The netsh command plan is built in portable code. That puts the delete-every- existing-address step — the one that stops secondary addresses leaking across switches — under test without needing a Windows box. The editor requires the session token in a header for mutations, not just the cookie: any page in the browser can make it POST to 127.0.0.1 with the cookie attached, but it cannot set a header. The updater refuses to install a release that publishes no SHA256. Not yet done: no group picker for export (the API supports it), no single-instance guard, and internal/server/web/app.css is reconstructed from the description in claude.md rather than the canonical apointless.css. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
// Package applog gives ipswap a rotating log file. Every netsh invocation and
|
||||
// its raw output lands here, which is the only forensic trail available when a
|
||||
// switch misbehaves on a customer site.
|
||||
package applog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
maxBytes = 1 << 20 // 1 MB per file
|
||||
maxFiles = 5 // ipswap.log plus ipswap.log.1 .. .4
|
||||
)
|
||||
|
||||
// Writer is an io.Writer that rotates at maxBytes and keeps maxFiles files.
|
||||
// It is small on purpose: a rotation dependency for one log file is not worth
|
||||
// the supply chain.
|
||||
type Writer struct {
|
||||
path string
|
||||
|
||||
mu sync.Mutex
|
||||
f *os.File
|
||||
size int64
|
||||
}
|
||||
|
||||
// New opens the log at path, creating the directory if needed.
|
||||
func New(path string) (*Writer, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("creating log directory: %w", err)
|
||||
}
|
||||
w := &Writer{path: path}
|
||||
if err := w.open(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (w *Writer) open() error {
|
||||
f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening %s: %w", w.path, err)
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("stat %s: %w", w.path, err)
|
||||
}
|
||||
w.f, w.size = f, info.Size()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Writer) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if w.f == nil {
|
||||
return len(p), nil // closed; drop rather than error out a log call
|
||||
}
|
||||
if w.size+int64(len(p)) > maxBytes {
|
||||
if err := w.rotate(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
n, err := w.f.Write(p)
|
||||
w.size += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// rotate shifts ipswap.log.N to N+1 and starts a fresh ipswap.log. The caller
|
||||
// holds w.mu.
|
||||
func (w *Writer) rotate() error {
|
||||
if err := w.f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
w.f = nil
|
||||
|
||||
// Walk downwards so nothing is overwritten before it has been moved.
|
||||
for i := maxFiles - 1; i >= 1; i-- {
|
||||
older := fmt.Sprintf("%s.%d", w.path, i)
|
||||
if i == maxFiles-1 {
|
||||
os.Remove(older) // the oldest file falls off the end
|
||||
}
|
||||
var newer string
|
||||
if i == 1 {
|
||||
newer = w.path
|
||||
} else {
|
||||
newer = fmt.Sprintf("%s.%d", w.path, i-1)
|
||||
}
|
||||
if _, err := os.Stat(newer); err == nil {
|
||||
os.Rename(newer, older)
|
||||
}
|
||||
}
|
||||
return w.open()
|
||||
}
|
||||
|
||||
// Close closes the underlying file.
|
||||
func (w *Writer) Close() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.f == nil {
|
||||
return nil
|
||||
}
|
||||
err := w.f.Close()
|
||||
w.f = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// Setup points the standard logger at the rotating file and returns it so the
|
||||
// caller can close it on exit.
|
||||
//
|
||||
// With -H windowsgui there is no console, so stderr goes nowhere on Windows and
|
||||
// the file is the only output that survives. During development on Linux the
|
||||
// log is also echoed to stderr.
|
||||
func Setup(path string, alsoStderr bool) (*Writer, error) {
|
||||
w, err := New(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out io.Writer = w
|
||||
if alsoStderr {
|
||||
out = io.MultiWriter(w, os.Stderr)
|
||||
}
|
||||
log.SetOutput(out)
|
||||
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
||||
return w, nil
|
||||
}
|
||||
Reference in New Issue
Block a user