GHOSTWIRE 0.1.2: WireGuard server manager with web UI and API
Single Go binary that manages a WireGuard server based on pivpn's defaults: - config.json as the single source of truth, reconciled to the kernel via netlink, wgctrl and its own nftables table (NAT, forward, input) - web interface (dashboard, peers, peer detail, add peer, server, settings) and a JSON API for the future iOS app, with session and API-token auth - client private keys are never stored; configs and QR codes shown once - per-peer traffic statistics in stats.json, logs in GHOSTWIRE.jsonl - HTTPS via Let's Encrypt, self-signed, certificate files or off - self-managing: install, update (restores the old binary on failure), uninstall and passwd subcommands; systemd unit generated by the binary Tested end to end on Ubuntu 26.04 (kernel 7.0) at dev.redetzke.aero. Claude-Session: https://claude.ai/code/session_01RAnLbyQZ5ZTA7KqwXP98nw
This commit is contained in:
+146
@@ -0,0 +1,146 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var logLevel = new(slog.LevelVar)
|
||||
|
||||
func parseLevel(s string) slog.Level {
|
||||
var l slog.Level
|
||||
if err := l.UnmarshalText([]byte(s)); err != nil {
|
||||
return slog.LevelInfo
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// rotatingWriter appends to <app>.jsonl and rotates it to .1, .2, ... when it
|
||||
// grows past maxBytes, keeping maxFiles old files.
|
||||
type rotatingWriter struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
maxBytes int64
|
||||
maxFiles int
|
||||
f *os.File
|
||||
size int64
|
||||
}
|
||||
|
||||
func newRotatingWriter(path string, maxMB, maxFiles int) (*rotatingWriter, error) {
|
||||
w := &rotatingWriter{path: path, maxBytes: int64(maxMB) << 20, maxFiles: maxFiles}
|
||||
return w, w.open()
|
||||
}
|
||||
|
||||
func (w *rotatingWriter) open() error {
|
||||
f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
w.f, w.size = f, st.Size()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *rotatingWriter) rotate() error {
|
||||
w.f.Close()
|
||||
for i := w.maxFiles - 1; i >= 1; i-- {
|
||||
_ = os.Rename(fmt.Sprintf("%s.%d", w.path, i), fmt.Sprintf("%s.%d", w.path, i+1))
|
||||
}
|
||||
_ = os.Remove(fmt.Sprintf("%s.%d", w.path, w.maxFiles+1))
|
||||
if err := os.Rename(w.path, w.path+".1"); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.open()
|
||||
}
|
||||
|
||||
func (w *rotatingWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.size+int64(len(p)) > w.maxBytes && w.size > 0 {
|
||||
if err := w.rotate(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "log rotation failed:", err)
|
||||
}
|
||||
}
|
||||
n, err := w.f.Write(p)
|
||||
w.size += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (w *rotatingWriter) Close() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.f.Close()
|
||||
}
|
||||
|
||||
func setupLogging(path string, c LogConfig) (*rotatingWriter, error) {
|
||||
w, err := newRotatingWriter(path, c.MaxSizeMB, c.MaxFiles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logLevel.Set(parseLevel(c.Level))
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{Level: logLevel})))
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// readLogTail returns up to limit lines, newest first. level filters to that
|
||||
// level and above; auditOnly keeps only change records.
|
||||
func readLogTail(path string, limit int, level string, auditOnly bool) ([]json.RawMessage, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
const window = 1 << 20
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
off := max(st.Size()-window, 0)
|
||||
if _, err := f.Seek(off, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines := bytes.Split(b, []byte("\n"))
|
||||
if off > 0 && len(lines) > 0 {
|
||||
lines = lines[1:] // first line is probably cut
|
||||
}
|
||||
min := slog.Level(-100)
|
||||
if level != "" && level != "all" {
|
||||
min = parseLevel(level)
|
||||
}
|
||||
out := []json.RawMessage{}
|
||||
for i := len(lines) - 1; i >= 0 && len(out) < limit; i-- {
|
||||
line := bytes.TrimSpace(lines[i])
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var rec struct {
|
||||
Level string `json:"level"`
|
||||
Audit bool `json:"audit"`
|
||||
}
|
||||
if json.Unmarshal(line, &rec) != nil {
|
||||
continue
|
||||
}
|
||||
if auditOnly && !rec.Audit {
|
||||
continue
|
||||
}
|
||||
if parseLevel(strings.ToLower(rec.Level)) < min {
|
||||
continue
|
||||
}
|
||||
out = append(out, json.RawMessage(bytes.Clone(line)))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user