ConfigService.StartOIDCLogin(): - Fetches OIDC config from the API - Generates PKCE params - Starts local HTTP server on 127.0.0.1:18901 - Opens system browser to Authentik - Captures callback directly (no copy-paste) - Exchanges code for token, saves to keychain - Returns token to SPA → auto-connects Config.svelte detects Wails environment and calls the binding.
553 lines
13 KiB
Go
553 lines
13 KiB
Go
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(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Label</key>
|
|
<string>com.hubris.oikos-desktop</string>
|
|
<key>ProgramArguments</key>
|
|
<array>
|
|
<string>%s</string>
|
|
</array>
|
|
<key>RunAtLoad</key>
|
|
<true/>
|
|
<key>KeepAlive</key>
|
|
<false/>
|
|
</dict>
|
|
</plist>`, 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(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Oikos</title>
|
|
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0a0a0a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
|
.card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:32px;max-width:400px;text-align:center}
|
|
h1{font-size:18px;margin-bottom:8px}.ok{color:#22c55e;font-size:14px}</style>
|
|
</head><body><div class="card"><h1>Connected</h1><p class="ok">You can close this window and return to Oikos.</p></div></body></html>`))
|
|
|
|
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 := `<script>window.__OIKOS_CONFIG__ = {};</script>`
|
|
injected := fmt.Sprintf(`<script>window.__OIKOS_CONFIG__ = %s;</script>`, 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)
|
|
}
|
|
}
|