package main import ( "crypto/rand" "crypto/sha256" "embed" "encoding/base64" "encoding/json" "fmt" "io" "io/fs" "log" "net" "net/http" "net/url" "os" "os/exec" "os/user" "path/filepath" "runtime" "strings" "time" "github.com/wailsapp/wails/v3/pkg/application" "github.com/zalando/go-keyring" ) //go:embed frontend/dist var assets embed.FS //go:embed icon.png var iconPNG []byte const ( keyringService = "com.hubris.oikos-desktop" keyringUser = "oikos" version = "0.1.0" updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases" pollInterval = 30 * time.Second updateInterval = 6 * time.Hour oidcCallbackPort = 18901 ) type OikosConfig struct { ApiUrl string `json:"apiUrl"` Token string `json:"token,omitempty"` IsDesktop bool `json:"isDesktop"` } // ---- ConfigService ---- type ConfigService struct{} func (c *ConfigService) Name() string { return "config" } func (c *ConfigService) SaveConfig(apiUrl, token string) error { cfg := OikosConfig{ApiUrl: apiUrl, Token: token, IsDesktop: true} data, _ := json.Marshal(cfg) return keyring.Set(keyringService, keyringUser, string(data)) } func (c *ConfigService) ClearConfig() error { return keyring.Delete(keyringService, keyringUser) } func (c *ConfigService) EnableAutoStart() error { if runtime.GOOS != "darwin" { return fmt.Errorf("autostart not supported on %s", runtime.GOOS) } usr, _ := user.Current() dir := filepath.Join(usr.HomeDir, "Library", "LaunchAgents") os.MkdirAll(dir, 0755) exe, _ := os.Executable() plist := fmt.Sprintf(` Label com.hubris.oikos-desktop ProgramArguments %s RunAtLoad KeepAlive `, exe) return os.WriteFile(filepath.Join(dir, "com.hubris.oikos-desktop.plist"), []byte(plist), 0644) } func (c *ConfigService) DisableAutoStart() error { if runtime.GOOS != "darwin" { return fmt.Errorf("autostart not supported on %s", runtime.GOOS) } usr, _ := user.Current() path := filepath.Join(usr.HomeDir, "Library", "LaunchAgents", "com.hubris.oikos-desktop.plist") return os.Remove(path) } // StartOIDCLogin opens the system browser for Authentik login and returns // the access token. The desktop app hosts a local HTTP server on a fixed // port to receive the OIDC callback directly (no copy-paste). func (c *ConfigService) StartOIDCLogin(apiUrl string) (string, error) { apiUrl = strings.TrimRight(apiUrl, "/") oidcCfg, err := fetchOIDCConfig(apiUrl) if err != nil { return "", fmt.Errorf("OIDC config: %w", err) } verifier, challenge, err := pkceParams() if err != nil { return "", err } state := randomString(32) redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", oidcCallbackPort) type result struct { token string err error } done := make(chan result, 1) mux := http.NewServeMux() mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { code := r.URL.Query().Get("code") gotState := r.URL.Query().Get("state") if gotState != state { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("State mismatch.")) done <- result{err: fmt.Errorf("state mismatch")} return } token, err := exchangeCode(apiUrl, code, verifier, redirectURI) if err != nil { w.WriteHeader(http.StatusInternalServerError) fmt.Fprintf(w, "Token exchange failed: %v", err) done <- result{err: err} return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(`Oikos

Connected

You can close this window and return to Oikos.

`)) done <- result{token: token} }) listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", oidcCallbackPort)) if err != nil { return "", fmt.Errorf("port %d in use: %w", oidcCallbackPort, err) } srv := &http.Server{Handler: mux} go srv.Serve(listener) defer func() { srv.Close() listener.Close() }() authURL := fmt.Sprintf("%s/authorize/?%s", strings.TrimRight(oidcCfg.AuthorizationEndpoint, "/"), url.Values{ "response_type": {"code"}, "client_id": {oidcCfg.ClientID}, "redirect_uri": {redirectURI}, "code_challenge": {challenge}, "code_challenge_method": {"S256"}, "state": {state}, "scope": {"openid profile email"}, }.Encode(), ) if err := exec.Command("open", authURL).Start(); err != nil { return "", fmt.Errorf("open browser: %w", err) } select { case r := <-done: if r.err != nil { return "", r.err } c.SaveConfig(apiUrl, r.token) return r.token, nil case <-time.After(5 * time.Minute): return "", fmt.Errorf("login timed out") } } type oidcConfig struct { Issuer string `json:"issuer"` ClientID string `json:"client_id"` AuthorizationEndpoint string `json:"authorization_endpoint"` } func fetchOIDCConfig(apiUrl string) (*oidcConfig, error) { resp, err := http.Get(apiUrl + "/api/v1/auth/oidc-config") if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != 200 { return nil, fmt.Errorf("server returned %d", resp.StatusCode) } var cfg oidcConfig if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil { return nil, err } return &cfg, nil } func pkceParams() (verifier, challenge string, _ error) { v := randomString(64) h := sha256.Sum256([]byte(v)) return v, base64.RawURLEncoding.EncodeToString(h[:]), nil } func randomString(n int) string { b := make([]byte, n) rand.Read(b) return base64.RawURLEncoding.EncodeToString(b) } func exchangeCode(apiUrl, code, verifier, redirectURI string) (string, error) { body, _ := json.Marshal(map[string]string{ "grant_type": "authorization_code", "code": code, "code_verifier": verifier, "redirect_uri": redirectURI, }) resp, err := http.Post(apiUrl+"/api/v1/auth/oidc-token", "application/json", strings.NewReader(string(body))) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != 200 { b, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("token endpoint: %d — %s", resp.StatusCode, string(b)) } var tokens struct { AccessToken string `json:"access_token"` } if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil { return "", err } if tokens.AccessToken == "" { return "", fmt.Errorf("no access_token in response") } return tokens.AccessToken, nil } // ---- Window persistence ---- type windowState struct { X int `json:"x"` Y int `json:"y"` Width int `json:"width"` Height int `json:"height"` } func windowStatePath() string { usr, _ := user.Current() return filepath.Join(usr.HomeDir, ".config", "oikos", "window.json") } func loadWindowState() *windowState { data, err := os.ReadFile(windowStatePath()) if err != nil { return nil } var ws windowState if err := json.Unmarshal(data, &ws); err != nil { return nil } if ws.Width < 200 || ws.Height < 200 { return nil } return &ws } func saveWindowState(w application.Window) { x, y := w.Position() width, height := w.Size() ws := windowState{X: x, Y: y, Width: width, Height: height} data, _ := json.Marshal(ws) usr, _ := user.Current() dir := filepath.Join(usr.HomeDir, ".config", "oikos") os.MkdirAll(dir, 0755) os.WriteFile(filepath.Join(dir, "window.json"), data, 0644) } // ---- Config loading ---- func loadConfig() *OikosConfig { data, err := keyring.Get(keyringService, keyringUser) if err != nil { return nil } var cfg OikosConfig if err := json.Unmarshal([]byte(data), &cfg); err != nil { return nil } cfg.IsDesktop = true return &cfg } // ---- Asset handler ---- func newAssetHandler(cfg *OikosConfig) http.Handler { distFS, err := fs.Sub(assets, "frontend/dist") if err != nil { log.Fatalf("embedded assets: %v", err) } fallback := http.FileServer(http.FS(distFS)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if path == "/" || path == "/index.html" { data, err := fs.ReadFile(distFS, "index.html") if err != nil { fallback.ServeHTTP(w, r) return } html := string(data) if cfg != nil { configJSON, _ := json.Marshal(cfg) placeholder := `` injected := fmt.Sprintf(``, configJSON) html = strings.ReplaceAll(html, placeholder, injected) } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(html)) return } fallback.ServeHTTP(w, r) }) } // ---- Notifications ---- type dashboardSummary struct { ApprovalsPending int `json:"approvals_pending"` Signals struct { Critical int `json:"critical"` } `json:"signals_by_severity"` } func (d *dashboardSummary) alertCount() int { return d.ApprovalsPending + d.Signals.Critical } func notify(title, subtitle string) { if runtime.GOOS != "darwin" { return } script := fmt.Sprintf( `display notification "%s" with title "%s" sound name "default"`, strings.ReplaceAll(subtitle, `"`, `\"`), strings.ReplaceAll(title, `"`, `\"`), ) exec.Command("osascript", "-e", script).Run() } func pollDashboard(cfg *OikosConfig) { if cfg == nil || cfg.ApiUrl == "" || cfg.Token == "" { return } var lastCount int first := true for { req, err := http.NewRequest("GET", cfg.ApiUrl+"/api/v1/dashboard/summary", nil) if err != nil { time.Sleep(pollInterval) continue } req.Header.Set("Authorization", "Bearer "+cfg.Token) resp, err := http.DefaultClient.Do(req) if err != nil { time.Sleep(pollInterval) continue } body, _ := io.ReadAll(resp.Body) resp.Body.Close() var summary dashboardSummary if err := json.Unmarshal(body, &summary); err != nil { time.Sleep(pollInterval) continue } if first { lastCount = summary.alertCount() first = false } else { current := summary.alertCount() if current > lastCount { notify("Oikos", fmt.Sprintf("%d pending approval(s), %d critical signal(s)", summary.ApprovalsPending, summary.Signals.Critical)) } lastCount = current } time.Sleep(pollInterval) } } // ---- Auto-update ---- type giteaRelease struct { TagName string `json:"tag_name"` Assets []struct { Name string `json:"name"` BrowserDownloadURL string `json:"browser_download_url"` } `json:"assets"` } func checkUpdates() { for { resp, err := http.Get(updateURL + "?draft=false&pre-release=false&limit=1") if err != nil { time.Sleep(updateInterval) continue } body, _ := io.ReadAll(resp.Body) resp.Body.Close() var releases []giteaRelease if err := json.Unmarshal(body, &releases); err != nil || len(releases) == 0 { time.Sleep(updateInterval) continue } latest := releases[0] latestVersion := strings.TrimPrefix(latest.TagName, "v") if latestVersion == version { time.Sleep(updateInterval) continue } app := application.Get() if app == nil { time.Sleep(updateInterval) continue } msg := fmt.Sprintf("Version %s is available (you have %s). Download from Gitea releases.", latestVersion, version) app.Dialog.Info(). SetTitle("Update Available"). SetMessage(msg). Show() time.Sleep(updateInterval) } } // ---- Main ---- func main() { cfg := loadConfig() app := application.New(application.Options{ Name: "Oikos", Description: "Homelab Control Room", Services: []application.Service{ application.NewService(&ConfigService{}), }, Assets: application.AssetOptions{ Handler: newAssetHandler(cfg), }, Mac: application.MacOptions{ ApplicationShouldTerminateAfterLastWindowClosed: false, }, }) // --- System tray --- systemTray := app.SystemTray.New() systemTray.SetLabel("Oikos") systemTray.SetTooltip("Oikos") systemTray.SetIcon(iconPNG) systemTray.SetTemplateIcon(iconPNG) trayMenu := application.NewMenu() trayMenu.Add("Open Oikos").OnClick(func(ctx *application.Context) { for _, w := range app.Window.GetAll() { w.Show() w.Focus() } }) trayMenu.AddSeparator() trayMenu.Add("Check for Updates").OnClick(func(ctx *application.Context) { go checkUpdates() }) trayMenu.AddSeparator() trayMenu.Add("Quit").OnClick(func(ctx *application.Context) { app.Quit() }) systemTray.SetMenu(trayMenu) // --- Main window --- ws := loadWindowState() width, height := 1400, 900 minWidth, minHeight := 1024, 700 window := app.Window.NewWithOptions(application.WebviewWindowOptions{ Title: "Oikos", Width: width, Height: height, MinWidth: minWidth, MinHeight: minHeight, URL: "/", }) if ws != nil { window.SetPosition(ws.X, ws.Y) window.SetSize(ws.Width, ws.Height) } else { window.Center() } window.Show() systemTray.AttachWindow(window) systemTray.Run() app.OnShutdown(func() { saveWindowState(window) }) go pollDashboard(cfg) go checkUpdates() err := app.Run() if err != nil { log.Fatal(err) } }