No One Can Stop Me Now
This commit is contained in:
BIN
services/server-manager/autarch-server-manager
Normal file
BIN
services/server-manager/autarch-server-manager
Normal file
Binary file not shown.
BIN
services/server-manager/autarch-server-manager.exe
Normal file
BIN
services/server-manager/autarch-server-manager.exe
Normal file
Binary file not shown.
51
services/server-manager/build.sh
Normal file
51
services/server-manager/build.sh
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
# Build Autarch Server Manager
|
||||
# Usage: bash build.sh
|
||||
#
|
||||
# Targets: Linux AMD64 (Debian 13 server)
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " Building Autarch Server Manager"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo
|
||||
|
||||
# Resolve dependencies
|
||||
echo "[1/3] Resolving Go dependencies..."
|
||||
go mod tidy
|
||||
echo " ✔ Dependencies resolved"
|
||||
echo
|
||||
|
||||
# Build for Linux AMD64 (Debian 13 target)
|
||||
echo "[2/3] Building linux/amd64..."
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
|
||||
-ldflags="-s -w" \
|
||||
-o autarch-server-manager \
|
||||
./cmd/
|
||||
echo " ✔ autarch-server-manager ($(ls -lh autarch-server-manager | awk '{print $5}'))"
|
||||
echo
|
||||
|
||||
# Also build for current platform if different
|
||||
if [ "$(go env GOOS)" != "linux" ] || [ "$(go env GOARCH)" != "amd64" ]; then
|
||||
echo "[3/3] Building for current platform ($(go env GOOS)/$(go env GOARCH))..."
|
||||
go build \
|
||||
-ldflags="-s -w" \
|
||||
-o autarch-server-manager-local \
|
||||
./cmd/
|
||||
echo " ✔ autarch-server-manager-local"
|
||||
else
|
||||
echo "[3/3] Current platform is linux/amd64 — skipping duplicate build"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " Build complete!"
|
||||
echo ""
|
||||
echo " Deploy to server:"
|
||||
echo " scp autarch-server-manager root@server:/opt/autarch/"
|
||||
echo " ssh root@server /opt/autarch/autarch-server-manager"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
BIN
services/server-manager/cmd.exe
Normal file
BIN
services/server-manager/cmd.exe
Normal file
Binary file not shown.
25
services/server-manager/cmd/main.go
Normal file
25
services/server-manager/cmd/main.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/darkhal/autarch-server-manager/internal/tui"
|
||||
)
|
||||
|
||||
const version = "1.0.0"
|
||||
|
||||
func main() {
|
||||
if os.Geteuid() != 0 {
|
||||
fmt.Println("\033[91m[!] Autarch Server Manager requires root privileges.\033[0m")
|
||||
fmt.Println(" Run with: sudo ./autarch-server-manager")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
p := tea.NewProgram(tui.NewApp(), tea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
32
services/server-manager/go.mod
Normal file
32
services/server-manager/go.mod
Normal file
@@ -0,0 +1,32 @@
|
||||
module github.com/darkhal/autarch-server-manager
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
github.com/charmbracelet/bubbletea v1.3.4
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
golang.org/x/crypto v0.32.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.11.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
)
|
||||
51
services/server-manager/go.sum
Normal file
51
services/server-manager/go.sum
Normal file
@@ -0,0 +1,51 @@
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
|
||||
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
132
services/server-manager/internal/config/ini.go
Normal file
132
services/server-manager/internal/config/ini.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Package config provides INI-style configuration file parsing and editing
|
||||
// for autarch_settings.conf. It preserves comments and formatting.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ListSections returns all [section] names from an INI file.
|
||||
func ListSections(path string) ([]string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
|
||||
var sections []string
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
sec := line[1 : len(line)-1]
|
||||
sections = append(sections, sec)
|
||||
}
|
||||
}
|
||||
return sections, nil
|
||||
}
|
||||
|
||||
// GetSection returns all key-value pairs from a specific section.
|
||||
func GetSection(path, section string) (keys []string, vals []string, err error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
|
||||
inSection := false
|
||||
target := "[" + section + "]"
|
||||
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Check for section headers
|
||||
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
||||
inSection = (trimmed == target)
|
||||
continue
|
||||
}
|
||||
|
||||
if !inSection {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip comments and empty lines
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, ";") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse key = value
|
||||
eqIdx := strings.Index(trimmed, "=")
|
||||
if eqIdx < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(trimmed[:eqIdx])
|
||||
val := strings.TrimSpace(trimmed[eqIdx+1:])
|
||||
keys = append(keys, key)
|
||||
vals = append(vals, val)
|
||||
}
|
||||
|
||||
return keys, vals, nil
|
||||
}
|
||||
|
||||
// SetValue updates a single key in a section within the INI content string.
|
||||
// Returns the modified content. If the key doesn't exist, it's appended to the section.
|
||||
func SetValue(content, section, key, value string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
target := "[" + section + "]"
|
||||
inSection := false
|
||||
found := false
|
||||
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
||||
// If we were in our target section and didn't find the key, insert before this line
|
||||
if inSection && !found {
|
||||
lines[i] = key + " = " + value + "\n" + line
|
||||
found = true
|
||||
}
|
||||
inSection = (trimmed == target)
|
||||
continue
|
||||
}
|
||||
|
||||
if !inSection {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this line matches our key
|
||||
eqIdx := strings.Index(trimmed, "=")
|
||||
if eqIdx < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
lineKey := strings.TrimSpace(trimmed[:eqIdx])
|
||||
if lineKey == key {
|
||||
lines[i] = key + " = " + value
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
// If key wasn't found and we're still in section (or section was last), append
|
||||
if !found {
|
||||
if inSection {
|
||||
lines = append(lines, key+" = "+value)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// GetValue reads a single value from a section.
|
||||
func GetValue(path, section, key string) (string, error) {
|
||||
keys, vals, err := GetSection(path, section)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i, k := range keys {
|
||||
if k == key {
|
||||
return vals[i], nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("key %q not found in [%s]", key, section)
|
||||
}
|
||||
826
services/server-manager/internal/tui/app.go
Normal file
826
services/server-manager/internal/tui/app.go
Normal file
@@ -0,0 +1,826 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// ── View IDs ────────────────────────────────────────────────────────
|
||||
|
||||
type ViewID int
|
||||
|
||||
const (
|
||||
ViewMain ViewID = iota
|
||||
ViewDeps
|
||||
ViewDepsInstall
|
||||
ViewModules
|
||||
ViewModuleToggle
|
||||
ViewSettings
|
||||
ViewSettingsSection
|
||||
ViewSettingsEdit
|
||||
ViewUsers
|
||||
ViewUsersCreate
|
||||
ViewUsersReset
|
||||
ViewService
|
||||
ViewDNS
|
||||
ViewDNSBuild
|
||||
ViewDNSManage
|
||||
ViewDNSZones
|
||||
ViewDNSZoneEdit
|
||||
ViewDeploy
|
||||
ViewConfirm
|
||||
ViewResult
|
||||
)
|
||||
|
||||
// ── Styles ──────────────────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
colorRed = lipgloss.Color("#ef4444")
|
||||
colorGreen = lipgloss.Color("#22c55e")
|
||||
colorYellow = lipgloss.Color("#eab308")
|
||||
colorBlue = lipgloss.Color("#6366f1")
|
||||
colorCyan = lipgloss.Color("#06b6d4")
|
||||
colorMagenta = lipgloss.Color("#a855f7")
|
||||
colorDim = lipgloss.Color("#6b7280")
|
||||
colorWhite = lipgloss.Color("#f9fafb")
|
||||
colorSurface = lipgloss.Color("#1e1e2e")
|
||||
colorBorder = lipgloss.Color("#3b3b5c")
|
||||
|
||||
styleBanner = lipgloss.NewStyle().
|
||||
Foreground(colorRed).
|
||||
Bold(true)
|
||||
|
||||
styleTitle = lipgloss.NewStyle().
|
||||
Foreground(colorCyan).
|
||||
Bold(true).
|
||||
PaddingLeft(2)
|
||||
|
||||
styleSubtitle = lipgloss.NewStyle().
|
||||
Foreground(colorDim).
|
||||
PaddingLeft(2)
|
||||
|
||||
styleMenuItem = lipgloss.NewStyle().
|
||||
PaddingLeft(4)
|
||||
|
||||
styleSelected = lipgloss.NewStyle().
|
||||
Foreground(colorBlue).
|
||||
Bold(true).
|
||||
PaddingLeft(2)
|
||||
|
||||
styleNormal = lipgloss.NewStyle().
|
||||
Foreground(colorWhite).
|
||||
PaddingLeft(4)
|
||||
|
||||
styleKey = lipgloss.NewStyle().
|
||||
Foreground(colorCyan).
|
||||
Bold(true)
|
||||
|
||||
styleSuccess = lipgloss.NewStyle().
|
||||
Foreground(colorGreen)
|
||||
|
||||
styleError = lipgloss.NewStyle().
|
||||
Foreground(colorRed)
|
||||
|
||||
styleWarning = lipgloss.NewStyle().
|
||||
Foreground(colorYellow)
|
||||
|
||||
styleDim = lipgloss.NewStyle().
|
||||
Foreground(colorDim)
|
||||
|
||||
styleStatusOK = lipgloss.NewStyle().
|
||||
Foreground(colorGreen).
|
||||
Bold(true)
|
||||
|
||||
styleStatusBad = lipgloss.NewStyle().
|
||||
Foreground(colorRed).
|
||||
Bold(true)
|
||||
|
||||
styleBox = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorBorder).
|
||||
Padding(1, 2)
|
||||
|
||||
styleHR = lipgloss.NewStyle().
|
||||
Foreground(colorDim)
|
||||
)
|
||||
|
||||
// ── Menu Item ───────────────────────────────────────────────────────
|
||||
|
||||
type MenuItem struct {
|
||||
Key string
|
||||
Label string
|
||||
Desc string
|
||||
View ViewID
|
||||
}
|
||||
|
||||
// ── Messages ────────────────────────────────────────────────────────
|
||||
|
||||
type ResultMsg struct {
|
||||
Title string
|
||||
Lines []string
|
||||
IsError bool
|
||||
}
|
||||
|
||||
type ConfirmMsg struct {
|
||||
Prompt string
|
||||
OnConfirm func() tea.Cmd
|
||||
}
|
||||
|
||||
type OutputLineMsg string
|
||||
type DoneMsg struct{ Err error }
|
||||
|
||||
// ── App Model ───────────────────────────────────────────────────────
|
||||
|
||||
type App struct {
|
||||
width, height int
|
||||
|
||||
// Navigation
|
||||
view ViewID
|
||||
viewStack []ViewID
|
||||
cursor int
|
||||
|
||||
// Main menu
|
||||
mainMenu []MenuItem
|
||||
|
||||
// Dynamic content
|
||||
listItems []ListItem
|
||||
listTitle string
|
||||
sectionKeys []string
|
||||
|
||||
// Settings
|
||||
settingsSections []string
|
||||
settingsSection string
|
||||
settingsKeys []string
|
||||
settingsVals []string
|
||||
|
||||
// Text input
|
||||
textInput textinput.Model
|
||||
inputLabel string
|
||||
inputField string
|
||||
inputs []textinput.Model
|
||||
labels []string
|
||||
focusIdx int
|
||||
|
||||
// Result / output
|
||||
resultTitle string
|
||||
resultLines []string
|
||||
resultIsErr bool
|
||||
outputLines []string
|
||||
outputDone bool
|
||||
outputCh chan tea.Msg
|
||||
progressStep int
|
||||
progressTotal int
|
||||
progressLabel string
|
||||
|
||||
// Confirm
|
||||
confirmPrompt string
|
||||
confirmAction func() tea.Cmd
|
||||
|
||||
// Config path
|
||||
autarchDir string
|
||||
}
|
||||
|
||||
type ListItem struct {
|
||||
Name string
|
||||
Status string
|
||||
Enabled bool
|
||||
Extra string
|
||||
}
|
||||
|
||||
func NewApp() App {
|
||||
ti := textinput.New()
|
||||
ti.CharLimit = 256
|
||||
|
||||
app := App{
|
||||
view: ViewMain,
|
||||
autarchDir: findAutarchDir(),
|
||||
textInput: ti,
|
||||
mainMenu: []MenuItem{
|
||||
{Key: "1", Label: "Deploy AUTARCH", Desc: "Clone from GitHub, setup dirs, venv, deps, permissions, systemd", View: ViewDeploy},
|
||||
{Key: "2", Label: "Dependencies", Desc: "Install & manage system packages, Python venv, pip, npm", View: ViewDeps},
|
||||
{Key: "3", Label: "Modules", Desc: "List, enable, or disable AUTARCH Python modules", View: ViewModules},
|
||||
{Key: "4", Label: "Settings", Desc: "Edit autarch_settings.conf (all 14+ sections)", View: ViewSettings},
|
||||
{Key: "5", Label: "Users", Desc: "Create users, reset passwords, manage web credentials", View: ViewUsers},
|
||||
{Key: "6", Label: "Services", Desc: "Start, stop, restart AUTARCH web & background daemons", View: ViewService},
|
||||
{Key: "7", Label: "DNS Server", Desc: "Build, configure, and manage the AUTARCH DNS server", View: ViewDNS},
|
||||
{Key: "q", Label: "Quit", Desc: "Exit the server manager", View: ViewMain},
|
||||
},
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
func (a App) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForOutput returns a Cmd that reads the next message from the output channel.
|
||||
// This creates the streaming chain: OutputLineMsg → waitForOutput → OutputLineMsg → ...
|
||||
func (a App) waitForOutput() tea.Cmd {
|
||||
ch := a.outputCh
|
||||
if ch == nil {
|
||||
return nil
|
||||
}
|
||||
return func() tea.Msg {
|
||||
msg, ok := <-ch
|
||||
if !ok {
|
||||
return DoneMsg{}
|
||||
}
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update ──────────────────────────────────────────────────────────
|
||||
|
||||
func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
a.width = msg.Width
|
||||
a.height = msg.Height
|
||||
return a, nil
|
||||
|
||||
case ResultMsg:
|
||||
a.pushView(ViewResult)
|
||||
a.resultTitle = msg.Title
|
||||
a.resultLines = msg.Lines
|
||||
a.resultIsErr = msg.IsError
|
||||
return a, nil
|
||||
|
||||
case OutputLineMsg:
|
||||
a.outputLines = append(a.outputLines, string(msg))
|
||||
return a, a.waitForOutput()
|
||||
|
||||
case ProgressMsg:
|
||||
a.progressStep = msg.Step
|
||||
a.progressTotal = msg.Total
|
||||
a.progressLabel = msg.Label
|
||||
return a, a.waitForOutput()
|
||||
|
||||
case DoneMsg:
|
||||
a.outputDone = true
|
||||
a.outputCh = nil
|
||||
if msg.Err != nil {
|
||||
a.outputLines = append(a.outputLines, "", styleError.Render("Error: "+msg.Err.Error()))
|
||||
}
|
||||
a.outputLines = append(a.outputLines, "", styleDim.Render("Press any key to continue..."))
|
||||
return a, nil
|
||||
|
||||
case depsLoadedMsg:
|
||||
a.listItems = msg.items
|
||||
return a, nil
|
||||
case modulesLoadedMsg:
|
||||
a.listItems = msg.items
|
||||
return a, nil
|
||||
case settingsLoadedMsg:
|
||||
a.settingsSections = msg.sections
|
||||
return a, nil
|
||||
case dnsZonesMsg:
|
||||
a.listItems = msg.items
|
||||
return a, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
return a.handleKey(msg)
|
||||
}
|
||||
|
||||
// Update text inputs if active
|
||||
if a.isInputView() {
|
||||
return a.updateInputs(msg)
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a App) isInputView() bool {
|
||||
return a.view == ViewUsersCreate || a.view == ViewUsersReset ||
|
||||
a.view == ViewSettingsEdit || a.view == ViewDNSZoneEdit
|
||||
}
|
||||
|
||||
func (a App) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
key := msg.String()
|
||||
|
||||
// Global keys
|
||||
switch key {
|
||||
case "ctrl+c":
|
||||
return a, tea.Quit
|
||||
}
|
||||
|
||||
// Input views get special handling
|
||||
if a.isInputView() {
|
||||
return a.handleInputKey(msg)
|
||||
}
|
||||
|
||||
// Output view (streaming)
|
||||
if a.view == ViewDepsInstall || a.view == ViewDNSBuild {
|
||||
if a.outputDone {
|
||||
a.popView()
|
||||
a.progressStep = 0
|
||||
a.progressTotal = 0
|
||||
a.progressLabel = ""
|
||||
// Reload the parent view's data
|
||||
switch a.view {
|
||||
case ViewDeps:
|
||||
return a, a.loadDepsStatus()
|
||||
case ViewDNS:
|
||||
return a, nil
|
||||
case ViewDeploy:
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Result view
|
||||
if a.view == ViewResult {
|
||||
a.popView()
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Confirm view
|
||||
if a.view == ViewConfirm {
|
||||
switch key {
|
||||
case "y", "Y":
|
||||
if a.confirmAction != nil {
|
||||
cmd := a.confirmAction()
|
||||
a.popView()
|
||||
return a, cmd
|
||||
}
|
||||
a.popView()
|
||||
case "n", "N", "esc":
|
||||
a.popView()
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// List navigation
|
||||
switch key {
|
||||
case "up", "k":
|
||||
if a.cursor > 0 {
|
||||
a.cursor--
|
||||
}
|
||||
return a, nil
|
||||
case "down", "j":
|
||||
max := a.maxCursor()
|
||||
if a.cursor < max {
|
||||
a.cursor++
|
||||
}
|
||||
return a, nil
|
||||
case "esc":
|
||||
if len(a.viewStack) > 0 {
|
||||
a.popView()
|
||||
}
|
||||
return a, nil
|
||||
case "q":
|
||||
if a.view == ViewMain {
|
||||
return a, tea.Quit
|
||||
}
|
||||
if len(a.viewStack) > 0 {
|
||||
a.popView()
|
||||
return a, nil
|
||||
}
|
||||
return a, tea.Quit
|
||||
}
|
||||
|
||||
// View-specific handling
|
||||
switch a.view {
|
||||
case ViewMain:
|
||||
return a.handleMainMenu(key)
|
||||
case ViewDeps:
|
||||
return a.handleDepsMenu(key)
|
||||
case ViewModules:
|
||||
return a.handleModulesMenu(key)
|
||||
case ViewModuleToggle:
|
||||
return a.handleModuleToggle(key)
|
||||
case ViewSettings:
|
||||
return a.handleSettingsMenu(key)
|
||||
case ViewSettingsSection:
|
||||
return a.handleSettingsSection(key)
|
||||
case ViewUsers:
|
||||
return a.handleUsersMenu(key)
|
||||
case ViewService:
|
||||
return a.handleServiceMenu(key)
|
||||
case ViewDeploy:
|
||||
return a.handleDeployMenu(key)
|
||||
case ViewDNS:
|
||||
return a.handleDNSMenu(key)
|
||||
case ViewDNSManage:
|
||||
return a.handleDNSManageMenu(key)
|
||||
case ViewDNSZones:
|
||||
return a.handleDNSZonesMenu(key)
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a App) maxCursor() int {
|
||||
switch a.view {
|
||||
case ViewMain:
|
||||
return len(a.mainMenu) - 1
|
||||
case ViewModules, ViewModuleToggle:
|
||||
return len(a.listItems) - 1
|
||||
case ViewSettings:
|
||||
return len(a.settingsSections) - 1
|
||||
case ViewSettingsSection:
|
||||
return len(a.settingsKeys) - 1
|
||||
case ViewDNSZones:
|
||||
return len(a.listItems) - 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ── Navigation ──────────────────────────────────────────────────────
|
||||
|
||||
func (a *App) pushView(v ViewID) {
|
||||
a.viewStack = append(a.viewStack, a.view)
|
||||
a.view = v
|
||||
a.cursor = 0
|
||||
}
|
||||
|
||||
func (a *App) popView() {
|
||||
if len(a.viewStack) > 0 {
|
||||
a.view = a.viewStack[len(a.viewStack)-1]
|
||||
a.viewStack = a.viewStack[:len(a.viewStack)-1]
|
||||
a.cursor = 0
|
||||
}
|
||||
}
|
||||
|
||||
// ── View Rendering ──────────────────────────────────────────────────
|
||||
|
||||
func (a App) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(a.renderBanner())
|
||||
b.WriteString("\n")
|
||||
|
||||
switch a.view {
|
||||
case ViewMain:
|
||||
b.WriteString(a.renderMainMenu())
|
||||
case ViewDeploy:
|
||||
b.WriteString(a.renderDeployMenu())
|
||||
case ViewDeps:
|
||||
b.WriteString(a.renderDepsMenu())
|
||||
case ViewDepsInstall:
|
||||
b.WriteString(a.renderOutput("Installing Dependencies"))
|
||||
case ViewModules:
|
||||
b.WriteString(a.renderModulesList())
|
||||
case ViewModuleToggle:
|
||||
b.WriteString(a.renderModulesList())
|
||||
case ViewSettings:
|
||||
b.WriteString(a.renderSettingsSections())
|
||||
case ViewSettingsSection:
|
||||
b.WriteString(a.renderSettingsKeys())
|
||||
case ViewSettingsEdit:
|
||||
b.WriteString(a.renderSettingsEditForm())
|
||||
case ViewUsers:
|
||||
b.WriteString(a.renderUsersMenu())
|
||||
case ViewUsersCreate:
|
||||
b.WriteString(a.renderUserForm("Create New User"))
|
||||
case ViewUsersReset:
|
||||
b.WriteString(a.renderUserForm("Reset Password"))
|
||||
case ViewService:
|
||||
b.WriteString(a.renderServiceMenu())
|
||||
case ViewDNS:
|
||||
b.WriteString(a.renderDNSMenu())
|
||||
case ViewDNSBuild:
|
||||
b.WriteString(a.renderOutput("Building DNS Server"))
|
||||
case ViewDNSManage:
|
||||
b.WriteString(a.renderDNSManageMenu())
|
||||
case ViewDNSZones:
|
||||
b.WriteString(a.renderDNSZones())
|
||||
case ViewDNSZoneEdit:
|
||||
b.WriteString(a.renderDNSZoneForm())
|
||||
case ViewConfirm:
|
||||
b.WriteString(a.renderConfirm())
|
||||
case ViewResult:
|
||||
b.WriteString(a.renderResult())
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderStatusBar())
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Banner ──────────────────────────────────────────────────────────
|
||||
|
||||
func (a App) renderBanner() string {
|
||||
banner := `
|
||||
▄▄▄ █ ██ ▄▄▄█████▓ ▄▄▄ ██▀███ ▄████▄ ██░ ██
|
||||
▒████▄ ██ ▓██▒▓ ██▒ ▓▒▒████▄ ▓██ ▒ ██▒▒██▀ ▀█ ▓██░ ██▒
|
||||
▒██ ▀█▄ ▓██ ▒██░▒ ▓██░ ▒░▒██ ▀█▄ ▓██ ░▄█ ▒▒▓█ ▄ ▒██▀▀██░
|
||||
░██▄▄▄▄██ ▓▓█ ░██░░ ▓██▓ ░ ░██▄▄▄▄██ ▒██▀▀█▄ ▒▓▓▄ ▄██▒░▓█ ░██
|
||||
▓█ ▓██▒▒▒█████▓ ▒██▒ ░ ▓█ ▓██▒░██▓ ▒██▒▒ ▓███▀ ░░▓█▒░██▓
|
||||
▒▒ ▓▒█░░▒▓▒ ▒ ▒ ▒ ░░ ▒▒ ▓▒█░░ ▒▓ ░▒▓░░ ░▒ ▒ ░ ▒ ░░▒░▒
|
||||
▒ ▒▒ ░░░▒░ ░ ░ ░ ▒ ▒▒ ░ ░▒ ░ ▒░ ░ ▒ ▒ ░▒░ ░
|
||||
░ ▒ ░░░ ░ ░ ░ ░ ▒ ░░ ░ ░ ░ ░░ ░
|
||||
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░`
|
||||
|
||||
title := lipgloss.NewStyle().
|
||||
Foreground(colorCyan).
|
||||
Bold(true).
|
||||
Align(lipgloss.Center).
|
||||
Render("S E R V E R M A N A G E R v1.0")
|
||||
|
||||
sub := styleDim.Render(" darkHal Security Group & Setec Security Labs")
|
||||
|
||||
// Live service status bar
|
||||
statusLine := a.renderServiceStatusBar()
|
||||
|
||||
return styleBanner.Render(banner) + "\n" + title + "\n" + sub + "\n" + statusLine + "\n"
|
||||
}
|
||||
|
||||
func (a App) renderServiceStatusBar() string {
|
||||
webStatus, webUp := getProcessStatus("autarch-web", "autarch_web.py")
|
||||
dnsStatus, dnsUp := getProcessStatus("autarch-dns", "autarch-dns")
|
||||
|
||||
webInd := styleStatusBad.Render("○")
|
||||
if webUp {
|
||||
webInd = styleStatusOK.Render("●")
|
||||
}
|
||||
dnsInd := styleStatusBad.Render("○")
|
||||
if dnsUp {
|
||||
dnsInd = styleStatusOK.Render("●")
|
||||
}
|
||||
|
||||
_ = webStatus
|
||||
_ = dnsStatus
|
||||
|
||||
return styleDim.Render(" ") +
|
||||
webInd + styleDim.Render(" Web ") +
|
||||
dnsInd + styleDim.Render(" DNS")
|
||||
}
|
||||
|
||||
func (a App) renderHR() string {
|
||||
w := a.width
|
||||
if w < 10 {
|
||||
w = 66
|
||||
}
|
||||
if w > 80 {
|
||||
w = 80
|
||||
}
|
||||
return styleHR.Render(strings.Repeat("─", w-4)) + "\n"
|
||||
}
|
||||
|
||||
// ── Main Menu ───────────────────────────────────────────────────────
|
||||
|
||||
func (a App) renderMainMenu() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(styleTitle.Render("MAIN MENU"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
|
||||
for i, item := range a.mainMenu {
|
||||
cursor := " "
|
||||
if i == a.cursor {
|
||||
cursor = styleSelected.Render("▸ ")
|
||||
label := styleKey.Render("["+item.Key+"]") + " " +
|
||||
lipgloss.NewStyle().Foreground(colorWhite).Bold(true).Render(item.Label)
|
||||
desc := styleDim.Render(" " + item.Desc)
|
||||
b.WriteString(cursor + label + "\n")
|
||||
b.WriteString(" " + desc + "\n")
|
||||
} else {
|
||||
label := styleDim.Render("["+item.Key+"]") + " " +
|
||||
lipgloss.NewStyle().Foreground(colorWhite).Render(item.Label)
|
||||
b.WriteString(cursor + " " + label + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Confirm ─────────────────────────────────────────────────────────
|
||||
|
||||
func (a App) renderConfirm() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(styleTitle.Render("CONFIRM"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleWarning.Render(" " + a.confirmPrompt))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(styleDim.Render(" [Y] Yes [N] No"))
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Result ──────────────────────────────────────────────────────────
|
||||
|
||||
func (a App) renderResult() string {
|
||||
var b strings.Builder
|
||||
|
||||
title := a.resultTitle
|
||||
if a.resultIsErr {
|
||||
b.WriteString(styleError.Render(" " + title))
|
||||
} else {
|
||||
b.WriteString(styleSuccess.Render(" " + title))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
|
||||
for _, line := range a.resultLines {
|
||||
b.WriteString(" " + line + "\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleDim.Render(" Press any key to continue..."))
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Streaming Output ────────────────────────────────────────────────
|
||||
|
||||
func (a App) renderOutput(title string) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(styleTitle.Render(title))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
|
||||
// Progress bar
|
||||
if a.progressTotal > 0 && !a.outputDone {
|
||||
pct := float64(a.progressStep) / float64(a.progressTotal)
|
||||
barWidth := 40
|
||||
filled := int(pct * float64(barWidth))
|
||||
if filled > barWidth {
|
||||
filled = barWidth
|
||||
}
|
||||
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
|
||||
pctStr := fmt.Sprintf("%3.0f%%", pct*100)
|
||||
|
||||
b.WriteString(" " + styleKey.Render("["+bar+"]") + " " +
|
||||
styleWarning.Render(pctStr) + " " +
|
||||
styleDim.Render(fmt.Sprintf("Step %d/%d: %s", a.progressStep, a.progressTotal, a.progressLabel)))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Show last N lines that fit the screen
|
||||
maxLines := a.height - 22
|
||||
if maxLines < 10 {
|
||||
maxLines = 20
|
||||
}
|
||||
start := 0
|
||||
if len(a.outputLines) > maxLines {
|
||||
start = len(a.outputLines) - maxLines
|
||||
}
|
||||
for _, line := range a.outputLines[start:] {
|
||||
b.WriteString(" " + line + "\n")
|
||||
}
|
||||
|
||||
if !a.outputDone {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleDim.Render(" Working..."))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Status Bar ──────────────────────────────────────────────────────
|
||||
|
||||
func (a App) renderStatusBar() string {
|
||||
nav := styleDim.Render(" ↑↓ navigate")
|
||||
esc := styleDim.Render(" esc back")
|
||||
quit := styleDim.Render(" q quit")
|
||||
|
||||
path := ""
|
||||
for _, v := range a.viewStack {
|
||||
path += viewName(v) + " > "
|
||||
}
|
||||
path += viewName(a.view)
|
||||
|
||||
left := styleDim.Render(" " + path)
|
||||
right := nav + esc + quit
|
||||
|
||||
gap := a.width - lipgloss.Width(left) - lipgloss.Width(right)
|
||||
if gap < 1 {
|
||||
gap = 1
|
||||
}
|
||||
|
||||
return "\n" + styleHR.Render(strings.Repeat("─", clamp(a.width-4, 20, 80))) + "\n" +
|
||||
left + strings.Repeat(" ", gap) + right + "\n"
|
||||
}
|
||||
|
||||
func viewName(v ViewID) string {
|
||||
names := map[ViewID]string{
|
||||
ViewMain: "Main",
|
||||
ViewDeps: "Dependencies",
|
||||
ViewDepsInstall: "Install",
|
||||
ViewModules: "Modules",
|
||||
ViewModuleToggle: "Toggle",
|
||||
ViewSettings: "Settings",
|
||||
ViewSettingsSection: "Section",
|
||||
ViewSettingsEdit: "Edit",
|
||||
ViewUsers: "Users",
|
||||
ViewUsersCreate: "Create",
|
||||
ViewUsersReset: "Reset",
|
||||
ViewService: "Services",
|
||||
ViewDNS: "DNS",
|
||||
ViewDNSBuild: "Build",
|
||||
ViewDNSManage: "Manage",
|
||||
ViewDNSZones: "Zones",
|
||||
ViewDeploy: "Deploy",
|
||||
ViewDNSZoneEdit: "Edit Zone",
|
||||
ViewConfirm: "Confirm",
|
||||
ViewResult: "Result",
|
||||
}
|
||||
if n, ok := names[v]; ok {
|
||||
return n
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
// ── User Form Rendering ─────────────────────────────────────────────
|
||||
|
||||
func (a App) renderUserForm(title string) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(styleTitle.Render(title))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
|
||||
for i, label := range a.labels {
|
||||
prefix := " "
|
||||
if i == a.focusIdx {
|
||||
prefix = styleSelected.Render("▸ ")
|
||||
}
|
||||
b.WriteString(prefix + styleDim.Render(label+": "))
|
||||
b.WriteString(a.inputs[i].View())
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleDim.Render(" tab next field | enter submit | esc cancel"))
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Settings Edit Form ──────────────────────────────────────────────
|
||||
|
||||
func (a App) renderSettingsEditForm() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(styleTitle.Render(fmt.Sprintf("Edit [%s]", a.settingsSection)))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
|
||||
for i, label := range a.labels {
|
||||
prefix := " "
|
||||
if i == a.focusIdx {
|
||||
prefix = styleSelected.Render("▸ ")
|
||||
}
|
||||
b.WriteString(prefix + styleKey.Render(label) + " = ")
|
||||
b.WriteString(a.inputs[i].View())
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleDim.Render(" tab next | enter save all | esc cancel"))
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── DNS Zone Form ───────────────────────────────────────────────────
|
||||
|
||||
func (a App) renderDNSZoneForm() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(styleTitle.Render("Create DNS Zone"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
|
||||
for i, label := range a.labels {
|
||||
prefix := " "
|
||||
if i == a.focusIdx {
|
||||
prefix = styleSelected.Render("▸ ")
|
||||
}
|
||||
b.WriteString(prefix + styleDim.Render(label+": "))
|
||||
b.WriteString(a.inputs[i].View())
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleDim.Render(" tab next field | enter submit | esc cancel"))
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
func clamp(v, lo, hi int) int {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
48
services/server-manager/internal/tui/helpers.go
Normal file
48
services/server-manager/internal/tui/helpers.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// findAutarchDir walks up from the server-manager binary location to find
|
||||
// the AUTARCH project root (identified by autarch_settings.conf).
|
||||
func findAutarchDir() string {
|
||||
// Try well-known paths first
|
||||
candidates := []string{
|
||||
"/opt/autarch",
|
||||
"/srv/autarch",
|
||||
"/home/autarch",
|
||||
}
|
||||
|
||||
// Also try relative to the executable
|
||||
exe, err := os.Executable()
|
||||
if err == nil {
|
||||
dir := filepath.Dir(exe)
|
||||
// services/server-manager/ → ../../
|
||||
candidates = append([]string{
|
||||
filepath.Join(dir, "..", ".."),
|
||||
filepath.Join(dir, ".."),
|
||||
dir,
|
||||
}, candidates...)
|
||||
}
|
||||
|
||||
// Also check cwd
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
candidates = append([]string{cwd, filepath.Join(cwd, "..", "..")}, candidates...)
|
||||
}
|
||||
|
||||
for _, c := range candidates {
|
||||
abs, err := filepath.Abs(c)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
conf := filepath.Join(abs, "autarch_settings.conf")
|
||||
if _, err := os.Stat(conf); err == nil {
|
||||
return abs
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return "/opt/autarch"
|
||||
}
|
||||
99
services/server-manager/internal/tui/inputs.go
Normal file
99
services/server-manager/internal/tui/inputs.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// ── Input View Handling ─────────────────────────────────────────────
|
||||
|
||||
func (a App) handleInputKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
key := msg.String()
|
||||
|
||||
switch key {
|
||||
case "esc":
|
||||
a.popView()
|
||||
return a, nil
|
||||
|
||||
case "tab", "shift+tab":
|
||||
// Cycle focus
|
||||
if key == "tab" {
|
||||
a.focusIdx = (a.focusIdx + 1) % len(a.inputs)
|
||||
} else {
|
||||
a.focusIdx = (a.focusIdx - 1 + len(a.inputs)) % len(a.inputs)
|
||||
}
|
||||
for i := range a.inputs {
|
||||
if i == a.focusIdx {
|
||||
a.inputs[i].Focus()
|
||||
} else {
|
||||
a.inputs[i].Blur()
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
|
||||
case "enter":
|
||||
// If not on last field, advance
|
||||
if a.focusIdx < len(a.inputs)-1 {
|
||||
a.focusIdx++
|
||||
for i := range a.inputs {
|
||||
if i == a.focusIdx {
|
||||
a.inputs[i].Focus()
|
||||
} else {
|
||||
a.inputs[i].Blur()
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Submit
|
||||
switch a.view {
|
||||
case ViewUsersCreate:
|
||||
return a.submitUserCreate()
|
||||
case ViewUsersReset:
|
||||
return a.submitUserReset()
|
||||
case ViewSettingsEdit:
|
||||
return a.saveSettings()
|
||||
case ViewDNSZoneEdit:
|
||||
return a.submitDNSZone()
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Forward key to focused input
|
||||
if a.focusIdx >= 0 && a.focusIdx < len(a.inputs) {
|
||||
var cmd tea.Cmd
|
||||
a.inputs[a.focusIdx], cmd = a.inputs[a.focusIdx].Update(msg)
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a App) updateInputs(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if a.focusIdx >= 0 && a.focusIdx < len(a.inputs) {
|
||||
var cmd tea.Cmd
|
||||
a.inputs[a.focusIdx], cmd = a.inputs[a.focusIdx].Update(msg)
|
||||
return a, cmd
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ── File Helpers (used by multiple views) ────────────────────────────
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func readFileBytes(path string) ([]byte, error) {
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
func writeFile(path string, data []byte, perm os.FileMode) error {
|
||||
return os.WriteFile(path, data, perm)
|
||||
}
|
||||
|
||||
func renameFile(src, dst string) error {
|
||||
return os.Rename(src, dst)
|
||||
}
|
||||
161
services/server-manager/internal/tui/streaming.go
Normal file
161
services/server-manager/internal/tui/streaming.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// ── Streaming Messages ──────────────────────────────────────────────
|
||||
|
||||
// ProgressMsg updates the progress bar in the output view.
|
||||
type ProgressMsg struct {
|
||||
Step int
|
||||
Total int
|
||||
Label string
|
||||
}
|
||||
|
||||
// ── Step Definition ─────────────────────────────────────────────────
|
||||
|
||||
// CmdStep defines a single command to run in a streaming sequence.
|
||||
type CmdStep struct {
|
||||
Label string // Human-readable label (shown in output)
|
||||
Args []string // Command + arguments
|
||||
Dir string // Working directory (empty = inherit)
|
||||
}
|
||||
|
||||
// ── Streaming Execution Engine ──────────────────────────────────────
|
||||
|
||||
// streamSteps runs a sequence of CmdSteps, sending OutputLineMsg per line
|
||||
// and ProgressMsg per step, then DoneMsg when finished.
|
||||
// It writes to a buffered channel that the TUI reads via waitForOutput().
|
||||
func streamSteps(ch chan<- tea.Msg, steps []CmdStep) {
|
||||
defer close(ch)
|
||||
|
||||
total := len(steps)
|
||||
var errors []string
|
||||
|
||||
for i, step := range steps {
|
||||
// Send progress update
|
||||
ch <- ProgressMsg{
|
||||
Step: i + 1,
|
||||
Total: total,
|
||||
Label: step.Label,
|
||||
}
|
||||
|
||||
// Show command being executed
|
||||
cmdStr := strings.Join(step.Args, " ")
|
||||
ch <- OutputLineMsg(styleKey.Render(fmt.Sprintf("═══ [%d/%d] %s ═══", i+1, total, step.Label)))
|
||||
ch <- OutputLineMsg(styleDim.Render(" $ " + cmdStr))
|
||||
|
||||
// Build command
|
||||
cmd := exec.Command(step.Args[0], step.Args[1:]...)
|
||||
if step.Dir != "" {
|
||||
cmd.Dir = step.Dir
|
||||
}
|
||||
|
||||
// Get pipes for real-time output
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
ch <- OutputLineMsg(styleError.Render(" Failed to create stdout pipe: " + err.Error()))
|
||||
errors = append(errors, step.Label+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
cmd.Stderr = cmd.Stdout // merge stderr into stdout
|
||||
|
||||
// Start command
|
||||
startTime := time.Now()
|
||||
if err := cmd.Start(); err != nil {
|
||||
ch <- OutputLineMsg(styleError.Render(" Failed to start: " + err.Error()))
|
||||
errors = append(errors, step.Label+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
// Read output line by line
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 64*1024), 256*1024) // handle long lines
|
||||
lineCount := 0
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
lineCount++
|
||||
|
||||
// Parse apt/pip progress indicators for speed display
|
||||
if parsed := parseProgressLine(line); parsed != "" {
|
||||
ch <- OutputLineMsg(" " + parsed)
|
||||
} else {
|
||||
// Throttle verbose output: show every line for first 30,
|
||||
// then every 5th line, but always show errors
|
||||
if lineCount <= 30 || lineCount%5 == 0 || isErrorLine(line) {
|
||||
ch <- OutputLineMsg(" " + line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for command to finish
|
||||
err = cmd.Wait()
|
||||
elapsed := time.Since(startTime)
|
||||
|
||||
if err != nil {
|
||||
ch <- OutputLineMsg(styleError.Render(fmt.Sprintf(" ✘ Failed (%s): %s", elapsed.Round(time.Millisecond), err.Error())))
|
||||
errors = append(errors, step.Label+": "+err.Error())
|
||||
} else {
|
||||
ch <- OutputLineMsg(styleSuccess.Render(fmt.Sprintf(" ✔ Done (%s)", elapsed.Round(time.Millisecond))))
|
||||
}
|
||||
ch <- OutputLineMsg("")
|
||||
}
|
||||
|
||||
// Final summary
|
||||
if len(errors) > 0 {
|
||||
ch <- OutputLineMsg(styleWarning.Render(fmt.Sprintf("═══ Completed with %d error(s) ═══", len(errors))))
|
||||
for _, e := range errors {
|
||||
ch <- OutputLineMsg(styleError.Render(" ✘ " + e))
|
||||
}
|
||||
ch <- DoneMsg{Err: fmt.Errorf("%d step(s) failed", len(errors))}
|
||||
} else {
|
||||
ch <- OutputLineMsg(styleSuccess.Render("═══ All steps completed successfully ═══"))
|
||||
ch <- DoneMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Progress Parsing ────────────────────────────────────────────────
|
||||
|
||||
// parseProgressLine extracts progress info from apt/pip/npm output.
|
||||
func parseProgressLine(line string) string {
|
||||
// apt progress: "Progress: [ 45%]" or percentage patterns
|
||||
if strings.Contains(line, "Progress:") || strings.Contains(line, "progress:") {
|
||||
return styleWarning.Render(strings.TrimSpace(line))
|
||||
}
|
||||
|
||||
// pip: "Downloading foo-1.2.3.whl (2.3 MB)" or "Installing collected packages:"
|
||||
if strings.HasPrefix(line, "Downloading ") || strings.HasPrefix(line, "Collecting ") {
|
||||
return styleCyan.Render(strings.TrimSpace(line))
|
||||
}
|
||||
if strings.HasPrefix(line, "Installing collected packages:") {
|
||||
return styleWarning.Render(strings.TrimSpace(line))
|
||||
}
|
||||
|
||||
// npm: "added X packages"
|
||||
if strings.Contains(line, "added") && strings.Contains(line, "packages") {
|
||||
return styleSuccess.Render(strings.TrimSpace(line))
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// isErrorLine checks if an output line looks like an error.
|
||||
func isErrorLine(line string) bool {
|
||||
lower := strings.ToLower(line)
|
||||
return strings.Contains(lower, "error") ||
|
||||
strings.Contains(lower, "failed") ||
|
||||
strings.Contains(lower, "fatal") ||
|
||||
strings.Contains(lower, "warning") ||
|
||||
strings.Contains(lower, "unable to")
|
||||
}
|
||||
|
||||
// ── Style for progress lines ────────────────────────────────────────
|
||||
|
||||
var styleCyan = styleKey // reuse existing cyan style
|
||||
493
services/server-manager/internal/tui/view_deploy.go
Normal file
493
services/server-manager/internal/tui/view_deploy.go
Normal file
@@ -0,0 +1,493 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
const (
|
||||
autarchGitRepo = "https://github.com/DigijEth/autarch.git"
|
||||
autarchBranch = "main"
|
||||
defaultInstDir = "/opt/autarch"
|
||||
)
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────
|
||||
|
||||
func (a App) renderDeployMenu() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(styleTitle.Render("DEPLOY AUTARCH"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
|
||||
installDir := defaultInstDir
|
||||
if a.autarchDir != "" && a.autarchDir != defaultInstDir {
|
||||
installDir = a.autarchDir
|
||||
}
|
||||
|
||||
// Check current state
|
||||
confExists := fileExists(filepath.Join(installDir, "autarch_settings.conf"))
|
||||
gitExists := fileExists(filepath.Join(installDir, ".git"))
|
||||
venvExists := fileExists(filepath.Join(installDir, "venv", "bin", "python3"))
|
||||
|
||||
b.WriteString(styleKey.Render(" Install directory: ") +
|
||||
lipgloss.NewStyle().Foreground(colorWhite).Bold(true).Render(installDir))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleKey.Render(" Git repository: ") +
|
||||
styleDim.Render(autarchGitRepo))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Status checks
|
||||
if gitExists {
|
||||
// Get current commit
|
||||
out, _ := exec.Command("git", "-C", installDir, "log", "--oneline", "-1").Output()
|
||||
commit := strings.TrimSpace(string(out))
|
||||
b.WriteString(" " + styleStatusOK.Render("✔ Git repo present") + " " + styleDim.Render(commit))
|
||||
} else {
|
||||
b.WriteString(" " + styleStatusBad.Render("✘ Not cloned"))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
if confExists {
|
||||
b.WriteString(" " + styleStatusOK.Render("✔ Config file present"))
|
||||
} else {
|
||||
b.WriteString(" " + styleStatusBad.Render("✘ No config file"))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
if venvExists {
|
||||
// Count pip packages
|
||||
out, _ := exec.Command(filepath.Join(installDir, "venv", "bin", "pip3"), "list", "--format=columns").Output()
|
||||
count := strings.Count(string(out), "\n") - 2
|
||||
if count < 0 {
|
||||
count = 0
|
||||
}
|
||||
b.WriteString(" " + styleStatusOK.Render(fmt.Sprintf("✔ Python venv (%d packages)", count)))
|
||||
} else {
|
||||
b.WriteString(" " + styleStatusBad.Render("✘ No Python venv"))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Check node_modules
|
||||
nodeExists := fileExists(filepath.Join(installDir, "node_modules"))
|
||||
if nodeExists {
|
||||
b.WriteString(" " + styleStatusOK.Render("✔ Node modules installed"))
|
||||
} else {
|
||||
b.WriteString(" " + styleStatusBad.Render("✘ No node_modules"))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Check services
|
||||
_, webUp := getProcessStatus("autarch-web", "autarch_web.py")
|
||||
_, dnsUp := getProcessStatus("autarch-dns", "autarch-dns")
|
||||
if webUp {
|
||||
b.WriteString(" " + styleStatusOK.Render("✔ Web service running"))
|
||||
} else {
|
||||
b.WriteString(" " + styleStatusBad.Render("○ Web service stopped"))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
if dnsUp {
|
||||
b.WriteString(" " + styleStatusOK.Render("✔ DNS service running"))
|
||||
} else {
|
||||
b.WriteString(" " + styleStatusBad.Render("○ DNS service stopped"))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
|
||||
if !gitExists {
|
||||
b.WriteString(styleKey.Render(" [c]") + " Clone AUTARCH from GitHub " + styleDim.Render("(full install)") + "\n")
|
||||
} else {
|
||||
b.WriteString(styleKey.Render(" [u]") + " Update (git pull + reinstall deps)\n")
|
||||
}
|
||||
b.WriteString(styleKey.Render(" [f]") + " Full setup " + styleDim.Render("(clone/pull + venv + pip + npm + build + systemd + permissions)") + "\n")
|
||||
b.WriteString(styleKey.Render(" [v]") + " Setup venv + pip install only\n")
|
||||
b.WriteString(styleKey.Render(" [n]") + " Setup npm + build hardware JS only\n")
|
||||
b.WriteString(styleKey.Render(" [p]") + " Fix permissions " + styleDim.Render("(chown/chmod)") + "\n")
|
||||
b.WriteString(styleKey.Render(" [s]") + " Install systemd service units\n")
|
||||
b.WriteString(styleKey.Render(" [d]") + " Build DNS server from source\n")
|
||||
b.WriteString(styleKey.Render(" [g]") + " Generate self-signed TLS cert\n")
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleDim.Render(" esc back"))
|
||||
b.WriteString("\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Key Handling ────────────────────────────────────────────────────
|
||||
|
||||
func (a App) handleDeployMenu(key string) (tea.Model, tea.Cmd) {
|
||||
switch key {
|
||||
case "c":
|
||||
return a.deployClone()
|
||||
case "u":
|
||||
return a.deployUpdate()
|
||||
case "f":
|
||||
return a.deployFull()
|
||||
case "v":
|
||||
return a.deployVenv()
|
||||
case "n":
|
||||
return a.deployNpm()
|
||||
case "p":
|
||||
return a.deployPermissions()
|
||||
case "s":
|
||||
return a.deploySystemd()
|
||||
case "d":
|
||||
return a.deployDNSBuild()
|
||||
case "g":
|
||||
return a.deployTLSCert()
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ── Deploy Commands ─────────────────────────────────────────────────
|
||||
|
||||
func (a App) deployClone() (App, tea.Cmd) {
|
||||
dir := defaultInstDir
|
||||
|
||||
// Quick check — if already cloned, show result without streaming
|
||||
if fileExists(filepath.Join(dir, ".git")) {
|
||||
return a, func() tea.Msg {
|
||||
return ResultMsg{
|
||||
Title: "Already Cloned",
|
||||
Lines: []string{"AUTARCH is already cloned at " + dir, "", "Use [u] to update or [f] for full setup."},
|
||||
IsError: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.pushView(ViewDepsInstall)
|
||||
a.outputLines = nil
|
||||
a.outputDone = false
|
||||
a.progressStep = 0
|
||||
a.progressTotal = 0
|
||||
|
||||
ch := make(chan tea.Msg, 256)
|
||||
a.outputCh = ch
|
||||
|
||||
go func() {
|
||||
os.MkdirAll(filepath.Dir(dir), 0755)
|
||||
steps := []CmdStep{
|
||||
{Label: "Clone AUTARCH from GitHub", Args: []string{"git", "clone", "--branch", autarchBranch, "--progress", autarchGitRepo, dir}},
|
||||
}
|
||||
streamSteps(ch, steps)
|
||||
}()
|
||||
|
||||
return a, a.waitForOutput()
|
||||
}
|
||||
|
||||
func (a App) deployUpdate() (App, tea.Cmd) {
|
||||
return a, func() tea.Msg {
|
||||
dir := defaultInstDir
|
||||
if a.autarchDir != "" {
|
||||
dir = a.autarchDir
|
||||
}
|
||||
var lines []string
|
||||
|
||||
// Git pull
|
||||
lines = append(lines, styleKey.Render("$ git -C "+dir+" pull"))
|
||||
cmd := exec.Command("git", "-C", dir, "pull", "--ff-only")
|
||||
out, err := cmd.CombinedOutput()
|
||||
for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
lines = append(lines, " "+l)
|
||||
}
|
||||
if err != nil {
|
||||
lines = append(lines, styleError.Render(" ✘ Pull failed: "+err.Error()))
|
||||
return ResultMsg{Title: "Update Failed", Lines: lines, IsError: true}
|
||||
}
|
||||
lines = append(lines, styleSuccess.Render(" ✔ Updated"))
|
||||
|
||||
return ResultMsg{Title: "AUTARCH Updated", Lines: lines}
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) deployFull() (App, tea.Cmd) {
|
||||
a.pushView(ViewDepsInstall)
|
||||
a.outputLines = nil
|
||||
a.outputDone = false
|
||||
a.progressStep = 0
|
||||
a.progressTotal = 0
|
||||
|
||||
ch := make(chan tea.Msg, 256)
|
||||
a.outputCh = ch
|
||||
|
||||
go func() {
|
||||
dir := defaultInstDir
|
||||
|
||||
var steps []CmdStep
|
||||
|
||||
// Step 1: Clone or pull
|
||||
if !fileExists(filepath.Join(dir, ".git")) {
|
||||
os.MkdirAll(filepath.Dir(dir), 0755)
|
||||
steps = append(steps, CmdStep{
|
||||
Label: "Clone AUTARCH from GitHub",
|
||||
Args: []string{"git", "clone", "--branch", autarchBranch, "--progress", autarchGitRepo, dir},
|
||||
})
|
||||
} else {
|
||||
steps = append(steps, CmdStep{
|
||||
Label: "Update from GitHub",
|
||||
Args: []string{"git", "-C", dir, "pull", "--ff-only"},
|
||||
})
|
||||
}
|
||||
|
||||
// Step 2: System deps
|
||||
steps = append(steps, CmdStep{
|
||||
Label: "Update package lists",
|
||||
Args: []string{"apt-get", "update", "-qq"},
|
||||
})
|
||||
aptPkgs := []string{
|
||||
"python3", "python3-pip", "python3-venv", "python3-dev",
|
||||
"build-essential", "cmake", "pkg-config",
|
||||
"git", "curl", "wget", "openssl",
|
||||
"libffi-dev", "libssl-dev", "libpcap-dev", "libxml2-dev", "libxslt1-dev",
|
||||
"nmap", "tshark", "whois", "dnsutils",
|
||||
"adb", "fastboot",
|
||||
"wireguard-tools", "miniupnpc", "net-tools",
|
||||
"nodejs", "npm", "ffmpeg",
|
||||
}
|
||||
steps = append(steps, CmdStep{
|
||||
Label: "Install system dependencies",
|
||||
Args: append([]string{"apt-get", "install", "-y"}, aptPkgs...),
|
||||
})
|
||||
|
||||
// Step 3: System user
|
||||
steps = append(steps, CmdStep{
|
||||
Label: "Create autarch system user",
|
||||
Args: []string{"useradd", "--system", "--no-create-home", "--shell", "/usr/sbin/nologin", "autarch"},
|
||||
})
|
||||
|
||||
// Step 4-5: Python venv + pip
|
||||
venv := filepath.Join(dir, "venv")
|
||||
pip := filepath.Join(venv, "bin", "pip3")
|
||||
steps = append(steps,
|
||||
CmdStep{Label: "Create Python virtual environment", Args: []string{"python3", "-m", "venv", venv}},
|
||||
CmdStep{Label: "Upgrade pip, setuptools, wheel", Args: []string{pip, "install", "--upgrade", "pip", "setuptools", "wheel"}},
|
||||
CmdStep{Label: "Install Python packages", Args: []string{pip, "install", "-r", filepath.Join(dir, "requirements.txt")}},
|
||||
)
|
||||
|
||||
// Step 6: npm
|
||||
steps = append(steps,
|
||||
CmdStep{Label: "Install npm packages", Args: []string{"npm", "install"}, Dir: dir},
|
||||
)
|
||||
if fileExists(filepath.Join(dir, "scripts", "build-hw-libs.sh")) {
|
||||
steps = append(steps, CmdStep{
|
||||
Label: "Build hardware JS bundles",
|
||||
Args: []string{"bash", "scripts/build-hw-libs.sh"},
|
||||
Dir: dir,
|
||||
})
|
||||
}
|
||||
|
||||
// Step 7: Permissions
|
||||
steps = append(steps,
|
||||
CmdStep{Label: "Set ownership", Args: []string{"chown", "-R", "root:root", dir}},
|
||||
CmdStep{Label: "Set permissions", Args: []string{"chmod", "-R", "755", dir}},
|
||||
)
|
||||
|
||||
// Step 8: Data directories (quick inline, not a CmdStep)
|
||||
dataDirs := []string{"data", "data/certs", "data/dns", "results", "dossiers", "models"}
|
||||
for _, d := range dataDirs {
|
||||
os.MkdirAll(filepath.Join(dir, d), 0755)
|
||||
}
|
||||
|
||||
// Step 9: Sensitive file permissions
|
||||
steps = append(steps,
|
||||
CmdStep{Label: "Secure config file", Args: []string{"chmod", "600", filepath.Join(dir, "autarch_settings.conf")}},
|
||||
)
|
||||
|
||||
// Step 10: TLS cert
|
||||
certDir := filepath.Join(dir, "data", "certs")
|
||||
certPath := filepath.Join(certDir, "autarch.crt")
|
||||
keyPath := filepath.Join(certDir, "autarch.key")
|
||||
if !fileExists(certPath) || !fileExists(keyPath) {
|
||||
steps = append(steps, CmdStep{
|
||||
Label: "Generate self-signed TLS certificate",
|
||||
Args: []string{"openssl", "req", "-x509", "-newkey", "rsa:2048",
|
||||
"-keyout", keyPath, "-out", certPath,
|
||||
"-days", "3650", "-nodes",
|
||||
"-subj", "/CN=AUTARCH/O=darkHal"},
|
||||
})
|
||||
}
|
||||
|
||||
// Step 11: Systemd units — write files inline then reload
|
||||
writeSystemdUnits(dir)
|
||||
steps = append(steps, CmdStep{
|
||||
Label: "Reload systemd daemon",
|
||||
Args: []string{"systemctl", "daemon-reload"},
|
||||
})
|
||||
|
||||
streamSteps(ch, steps)
|
||||
}()
|
||||
|
||||
return a, a.waitForOutput()
|
||||
}
|
||||
|
||||
func (a App) deployVenv() (App, tea.Cmd) {
|
||||
a.pushView(ViewDepsInstall)
|
||||
a.outputLines = nil
|
||||
a.outputDone = false
|
||||
a.progressStep = 0
|
||||
a.progressTotal = 0
|
||||
|
||||
ch := make(chan tea.Msg, 256)
|
||||
a.outputCh = ch
|
||||
|
||||
dir := resolveDir(a.autarchDir)
|
||||
go func() {
|
||||
streamSteps(ch, buildVenvSteps(dir))
|
||||
}()
|
||||
|
||||
return a, a.waitForOutput()
|
||||
}
|
||||
|
||||
func (a App) deployNpm() (App, tea.Cmd) {
|
||||
a.pushView(ViewDepsInstall)
|
||||
a.outputLines = nil
|
||||
a.outputDone = false
|
||||
a.progressStep = 0
|
||||
a.progressTotal = 0
|
||||
|
||||
ch := make(chan tea.Msg, 256)
|
||||
a.outputCh = ch
|
||||
|
||||
dir := resolveDir(a.autarchDir)
|
||||
go func() {
|
||||
streamSteps(ch, buildNpmSteps(dir))
|
||||
}()
|
||||
|
||||
return a, a.waitForOutput()
|
||||
}
|
||||
|
||||
func (a App) deployPermissions() (App, tea.Cmd) {
|
||||
return a, func() tea.Msg {
|
||||
dir := resolveDir(a.autarchDir)
|
||||
var lines []string
|
||||
|
||||
exec.Command("chown", "-R", "root:root", dir).Run()
|
||||
lines = append(lines, styleSuccess.Render("✔ chown -R root:root "+dir))
|
||||
|
||||
exec.Command("chmod", "-R", "755", dir).Run()
|
||||
lines = append(lines, styleSuccess.Render("✔ chmod -R 755 "+dir))
|
||||
|
||||
// Sensitive files
|
||||
confPath := filepath.Join(dir, "autarch_settings.conf")
|
||||
if fileExists(confPath) {
|
||||
exec.Command("chmod", "600", confPath).Run()
|
||||
lines = append(lines, styleSuccess.Render("✔ chmod 600 autarch_settings.conf"))
|
||||
}
|
||||
|
||||
credPath := filepath.Join(dir, "data", "web_credentials.json")
|
||||
if fileExists(credPath) {
|
||||
exec.Command("chmod", "600", credPath).Run()
|
||||
lines = append(lines, styleSuccess.Render("✔ chmod 600 web_credentials.json"))
|
||||
}
|
||||
|
||||
// Ensure data dirs exist
|
||||
for _, d := range []string{"data", "data/certs", "data/dns", "results", "dossiers", "models"} {
|
||||
os.MkdirAll(filepath.Join(dir, d), 0755)
|
||||
}
|
||||
lines = append(lines, styleSuccess.Render("✔ Data directories created"))
|
||||
|
||||
return ResultMsg{Title: "Permissions Fixed", Lines: lines}
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) deploySystemd() (App, tea.Cmd) {
|
||||
// Reuse the existing installServiceUnits
|
||||
return a.installServiceUnits()
|
||||
}
|
||||
|
||||
func (a App) deployDNSBuild() (App, tea.Cmd) {
|
||||
return a.buildDNSServer()
|
||||
}
|
||||
|
||||
func (a App) deployTLSCert() (App, tea.Cmd) {
|
||||
return a, func() tea.Msg {
|
||||
dir := resolveDir(a.autarchDir)
|
||||
certDir := filepath.Join(dir, "data", "certs")
|
||||
os.MkdirAll(certDir, 0755)
|
||||
|
||||
certPath := filepath.Join(certDir, "autarch.crt")
|
||||
keyPath := filepath.Join(certDir, "autarch.key")
|
||||
|
||||
cmd := exec.Command("openssl", "req", "-x509", "-newkey", "rsa:2048",
|
||||
"-keyout", keyPath, "-out", certPath,
|
||||
"-days", "3650", "-nodes",
|
||||
"-subj", "/CN=AUTARCH/O=darkHal Security Group")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return ResultMsg{
|
||||
Title: "Error",
|
||||
Lines: []string{string(out), err.Error()},
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
return ResultMsg{
|
||||
Title: "TLS Certificate Generated",
|
||||
Lines: []string{
|
||||
styleSuccess.Render("✔ Certificate: ") + certPath,
|
||||
styleSuccess.Render("✔ Private key: ") + keyPath,
|
||||
"",
|
||||
styleDim.Render("Valid for 10 years. Self-signed."),
|
||||
styleDim.Render("For production, use Let's Encrypt via Setec Manager."),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
func resolveDir(autarchDir string) string {
|
||||
if autarchDir != "" {
|
||||
return autarchDir
|
||||
}
|
||||
return defaultInstDir
|
||||
}
|
||||
|
||||
func writeSystemdUnits(dir string) {
|
||||
units := map[string]string{
|
||||
"autarch-web.service": fmt.Sprintf(`[Unit]
|
||||
Description=AUTARCH Web Dashboard
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=%s
|
||||
ExecStart=%s/venv/bin/python3 %s/autarch_web.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`, dir, dir, dir),
|
||||
"autarch-dns.service": fmt.Sprintf(`[Unit]
|
||||
Description=AUTARCH DNS Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=%s
|
||||
ExecStart=%s/services/dns-server/autarch-dns --config %s/data/dns/config.json
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`, dir, dir, dir),
|
||||
}
|
||||
for name, content := range units {
|
||||
path := "/etc/systemd/system/" + name
|
||||
os.WriteFile(path, []byte(content), 0644)
|
||||
}
|
||||
}
|
||||
416
services/server-manager/internal/tui/view_deps.go
Normal file
416
services/server-manager/internal/tui/view_deps.go
Normal file
@@ -0,0 +1,416 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// ── Dependency Categories ───────────────────────────────────────────
|
||||
|
||||
type depCheck struct {
|
||||
Name string
|
||||
Cmd string // command to check existence
|
||||
Pkg string // apt package name
|
||||
Kind string // "system", "python", "npm"
|
||||
Desc string
|
||||
}
|
||||
|
||||
var systemDeps = []depCheck{
|
||||
// Core runtime
|
||||
{"python3", "python3", "python3", "system", "Python 3.10+ interpreter"},
|
||||
{"pip", "pip3", "python3-pip", "system", "Python package manager"},
|
||||
{"python3-venv", "python3 -m venv --help", "python3-venv", "system", "Python virtual environments"},
|
||||
{"python3-dev", "python3-config --includes", "python3-dev", "system", "Python C headers (for native extensions)"},
|
||||
|
||||
// Build tools
|
||||
{"gcc", "gcc", "build-essential", "system", "C/C++ compiler toolchain"},
|
||||
{"cmake", "cmake", "cmake", "system", "CMake build system (for llama-cpp)"},
|
||||
{"pkg-config", "pkg-config", "pkg-config", "system", "Package config helper"},
|
||||
|
||||
// Core system utilities
|
||||
{"git", "git", "git", "system", "Version control"},
|
||||
{"curl", "curl", "curl", "system", "HTTP client"},
|
||||
{"wget", "wget", "wget", "system", "File downloader"},
|
||||
{"openssl", "openssl", "openssl", "system", "TLS/crypto toolkit"},
|
||||
|
||||
// C libraries for Python packages
|
||||
{"libffi-dev", "pkg-config --exists libffi", "libffi-dev", "system", "FFI library (for cffi/cryptography)"},
|
||||
{"libssl-dev", "pkg-config --exists openssl", "libssl-dev", "system", "OpenSSL headers (for cryptography)"},
|
||||
{"libpcap-dev", "pkg-config --exists libpcap", "libpcap-dev", "system", "Packet capture headers (for scapy)"},
|
||||
{"libxml2-dev", "pkg-config --exists libxml-2.0", "libxml2-dev", "system", "XML parser headers (for lxml)"},
|
||||
{"libxslt1-dev", "pkg-config --exists libxslt", "libxslt1-dev", "system", "XSLT headers (for lxml)"},
|
||||
|
||||
// Security tools
|
||||
{"nmap", "nmap", "nmap", "system", "Network scanner"},
|
||||
{"tshark", "tshark", "tshark", "system", "Packet analysis (Wireshark CLI)"},
|
||||
{"whois", "whois", "whois", "system", "WHOIS lookup"},
|
||||
{"dnsutils", "dig", "dnsutils", "system", "DNS utilities (dig, nslookup)"},
|
||||
|
||||
// Android tools
|
||||
{"adb", "adb", "adb", "system", "Android Debug Bridge"},
|
||||
{"fastboot", "fastboot", "fastboot", "system", "Android Fastboot"},
|
||||
|
||||
// Network tools
|
||||
{"wg", "wg", "wireguard-tools", "system", "WireGuard VPN tools"},
|
||||
{"upnpc", "upnpc", "miniupnpc", "system", "UPnP port mapping client"},
|
||||
{"net-tools", "ifconfig", "net-tools", "system", "Network utilities (ifconfig)"},
|
||||
|
||||
// Node.js
|
||||
{"node", "node", "nodejs", "system", "Node.js (for hardware WebUSB libs)"},
|
||||
{"npm", "npm", "npm", "system", "Node package manager"},
|
||||
|
||||
// Go
|
||||
{"go", "go", "golang", "system", "Go compiler (for DNS server build)"},
|
||||
|
||||
// Media / misc
|
||||
{"ffmpeg", "ffmpeg", "ffmpeg", "system", "Media processing"},
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────
|
||||
|
||||
func (a App) renderDepsMenu() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(styleTitle.Render("DEPENDENCIES"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
|
||||
if len(a.listItems) == 0 {
|
||||
b.WriteString(styleDim.Render(" Loading..."))
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Count installed vs total
|
||||
installed, total := 0, 0
|
||||
for _, item := range a.listItems {
|
||||
if item.Extra == "system" {
|
||||
total++
|
||||
if item.Enabled {
|
||||
installed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// System packages
|
||||
b.WriteString(styleKey.Render(fmt.Sprintf(" System Packages (%d/%d installed)", installed, total)))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
for _, item := range a.listItems {
|
||||
if item.Extra != "system" {
|
||||
continue
|
||||
}
|
||||
status := styleStatusOK.Render("✔ installed")
|
||||
if !item.Enabled {
|
||||
status = styleStatusBad.Render("✘ missing ")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %s %-14s %s\n", status, item.Name, styleDim.Render(item.Status)))
|
||||
}
|
||||
|
||||
// Python venv
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleKey.Render(" Python Virtual Environment"))
|
||||
b.WriteString("\n\n")
|
||||
for _, item := range a.listItems {
|
||||
if item.Extra != "venv" {
|
||||
continue
|
||||
}
|
||||
status := styleStatusOK.Render("✔ ready ")
|
||||
if !item.Enabled {
|
||||
status = styleStatusBad.Render("✘ missing")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %s %s\n", status, item.Name))
|
||||
}
|
||||
|
||||
// Actions
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleKey.Render(" [a]") + " Install all missing system packages\n")
|
||||
b.WriteString(styleKey.Render(" [v]") + " Create/recreate Python venv + install pip packages\n")
|
||||
b.WriteString(styleKey.Render(" [n]") + " Install npm packages + build hardware JS bundles\n")
|
||||
b.WriteString(styleKey.Render(" [f]") + " Full install (system + venv + pip + npm)\n")
|
||||
b.WriteString(styleKey.Render(" [g]") + " Install Go compiler (for DNS server)\n")
|
||||
b.WriteString(styleKey.Render(" [r]") + " Refresh status\n")
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleDim.Render(" esc back"))
|
||||
b.WriteString("\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Key Handling ────────────────────────────────────────────────────
|
||||
|
||||
func (a App) handleDepsMenu(key string) (tea.Model, tea.Cmd) {
|
||||
switch key {
|
||||
case "a":
|
||||
return a.startDepsInstall("system")
|
||||
case "v":
|
||||
return a.startDepsInstall("venv")
|
||||
case "n":
|
||||
return a.startDepsInstall("npm")
|
||||
case "f":
|
||||
return a.startDepsInstall("full")
|
||||
case "g":
|
||||
return a.startDepsInstall("go")
|
||||
case "r":
|
||||
return a, a.loadDepsStatus()
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ── Commands ────────────────────────────────────────────────────────
|
||||
|
||||
func (a App) loadDepsStatus() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
var items []ListItem
|
||||
|
||||
// Check system deps
|
||||
for _, d := range systemDeps {
|
||||
installed := false
|
||||
parts := strings.Fields(d.Cmd)
|
||||
if len(parts) == 1 {
|
||||
_, err := exec.LookPath(d.Cmd)
|
||||
installed = err == nil
|
||||
} else {
|
||||
cmd := exec.Command(parts[0], parts[1:]...)
|
||||
installed = cmd.Run() == nil
|
||||
}
|
||||
items = append(items, ListItem{
|
||||
Name: d.Name,
|
||||
Status: d.Desc,
|
||||
Enabled: installed,
|
||||
Extra: "system",
|
||||
})
|
||||
}
|
||||
|
||||
// Check venv
|
||||
venvPath := fmt.Sprintf("%s/venv", findAutarchDir())
|
||||
_, err := exec.LookPath(venvPath + "/bin/python3")
|
||||
items = append(items, ListItem{
|
||||
Name: "venv (" + venvPath + ")",
|
||||
Enabled: err == nil,
|
||||
Extra: "venv",
|
||||
})
|
||||
|
||||
// Check pip packages in venv
|
||||
venvPip := venvPath + "/bin/pip3"
|
||||
if _, err := exec.LookPath(venvPip); err == nil {
|
||||
out, _ := exec.Command(venvPip, "list", "--format=columns").Output()
|
||||
count := strings.Count(string(out), "\n") - 2
|
||||
if count < 0 {
|
||||
count = 0
|
||||
}
|
||||
items = append(items, ListItem{
|
||||
Name: fmt.Sprintf("pip packages (%d installed)", count),
|
||||
Enabled: count > 5,
|
||||
Extra: "venv",
|
||||
})
|
||||
} else {
|
||||
items = append(items, ListItem{
|
||||
Name: "pip packages (venv not found)",
|
||||
Enabled: false,
|
||||
Extra: "venv",
|
||||
})
|
||||
}
|
||||
|
||||
return depsLoadedMsg{items: items}
|
||||
}
|
||||
}
|
||||
|
||||
type depsLoadedMsg struct{ items []ListItem }
|
||||
|
||||
func (a App) startDepsInstall(mode string) (App, tea.Cmd) {
|
||||
a.pushView(ViewDepsInstall)
|
||||
a.outputLines = nil
|
||||
a.outputDone = false
|
||||
a.progressStep = 0
|
||||
a.progressTotal = 0
|
||||
a.progressLabel = ""
|
||||
|
||||
ch := make(chan tea.Msg, 256)
|
||||
a.outputCh = ch
|
||||
|
||||
autarchDir := findAutarchDir()
|
||||
|
||||
go func() {
|
||||
var steps []CmdStep
|
||||
|
||||
switch mode {
|
||||
case "system":
|
||||
steps = buildSystemInstallSteps()
|
||||
|
||||
case "venv":
|
||||
steps = buildVenvSteps(autarchDir)
|
||||
|
||||
case "npm":
|
||||
steps = buildNpmSteps(autarchDir)
|
||||
|
||||
case "go":
|
||||
steps = []CmdStep{
|
||||
{Label: "Update package lists", Args: []string{"apt-get", "update", "-qq"}},
|
||||
{Label: "Install Go compiler", Args: []string{"apt-get", "install", "-y", "golang"}},
|
||||
}
|
||||
|
||||
case "full":
|
||||
steps = buildSystemInstallSteps()
|
||||
steps = append(steps, buildVenvSteps(autarchDir)...)
|
||||
steps = append(steps, buildNpmSteps(autarchDir)...)
|
||||
}
|
||||
|
||||
if len(steps) == 0 {
|
||||
ch <- OutputLineMsg(styleSuccess.Render("Nothing to install — all dependencies are present."))
|
||||
close(ch)
|
||||
return
|
||||
}
|
||||
|
||||
streamSteps(ch, steps)
|
||||
}()
|
||||
|
||||
return a, a.waitForOutput()
|
||||
}
|
||||
|
||||
// ── Step Builders ───────────────────────────────────────────────────
|
||||
|
||||
func buildSystemInstallSteps() []CmdStep {
|
||||
// Collect missing packages
|
||||
var pkgs []string
|
||||
for _, d := range systemDeps {
|
||||
parts := strings.Fields(d.Cmd)
|
||||
if len(parts) == 1 {
|
||||
if _, err := exec.LookPath(d.Cmd); err != nil {
|
||||
pkgs = append(pkgs, d.Pkg)
|
||||
}
|
||||
} else {
|
||||
cmd := exec.Command(parts[0], parts[1:]...)
|
||||
if cmd.Run() != nil {
|
||||
pkgs = append(pkgs, d.Pkg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate packages (some deps share packages like build-essential)
|
||||
seen := make(map[string]bool)
|
||||
var uniquePkgs []string
|
||||
for _, p := range pkgs {
|
||||
if !seen[p] {
|
||||
seen[p] = true
|
||||
uniquePkgs = append(uniquePkgs, p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(uniquePkgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
steps := []CmdStep{
|
||||
{Label: "Update package lists", Args: []string{"apt-get", "update", "-qq"}},
|
||||
}
|
||||
|
||||
// Install in batches to show progress per category
|
||||
// Group: core runtime
|
||||
corePackages := filterPackages(uniquePkgs, []string{
|
||||
"python3", "python3-pip", "python3-venv", "python3-dev",
|
||||
"build-essential", "cmake", "pkg-config",
|
||||
"git", "curl", "wget", "openssl",
|
||||
})
|
||||
if len(corePackages) > 0 {
|
||||
steps = append(steps, CmdStep{
|
||||
Label: fmt.Sprintf("Install core packages (%s)", strings.Join(corePackages, ", ")),
|
||||
Args: append([]string{"apt-get", "install", "-y"}, corePackages...),
|
||||
})
|
||||
}
|
||||
|
||||
// Group: C library headers
|
||||
libPackages := filterPackages(uniquePkgs, []string{
|
||||
"libffi-dev", "libssl-dev", "libpcap-dev", "libxml2-dev", "libxslt1-dev",
|
||||
})
|
||||
if len(libPackages) > 0 {
|
||||
steps = append(steps, CmdStep{
|
||||
Label: fmt.Sprintf("Install C library headers (%s)", strings.Join(libPackages, ", ")),
|
||||
Args: append([]string{"apt-get", "install", "-y"}, libPackages...),
|
||||
})
|
||||
}
|
||||
|
||||
// Group: security & network tools
|
||||
toolPackages := filterPackages(uniquePkgs, []string{
|
||||
"nmap", "tshark", "whois", "dnsutils",
|
||||
"adb", "fastboot",
|
||||
"wireguard-tools", "miniupnpc", "net-tools",
|
||||
"ffmpeg",
|
||||
})
|
||||
if len(toolPackages) > 0 {
|
||||
steps = append(steps, CmdStep{
|
||||
Label: fmt.Sprintf("Install security/network tools (%s)", strings.Join(toolPackages, ", ")),
|
||||
Args: append([]string{"apt-get", "install", "-y"}, toolPackages...),
|
||||
})
|
||||
}
|
||||
|
||||
// Group: node + go
|
||||
devPackages := filterPackages(uniquePkgs, []string{
|
||||
"nodejs", "npm", "golang",
|
||||
})
|
||||
if len(devPackages) > 0 {
|
||||
steps = append(steps, CmdStep{
|
||||
Label: fmt.Sprintf("Install dev tools (%s)", strings.Join(devPackages, ", ")),
|
||||
Args: append([]string{"apt-get", "install", "-y"}, devPackages...),
|
||||
})
|
||||
}
|
||||
|
||||
return steps
|
||||
}
|
||||
|
||||
func buildVenvSteps(autarchDir string) []CmdStep {
|
||||
venv := autarchDir + "/venv"
|
||||
pip := venv + "/bin/pip3"
|
||||
reqFile := autarchDir + "/requirements.txt"
|
||||
|
||||
steps := []CmdStep{
|
||||
{Label: "Create Python virtual environment", Args: []string{"python3", "-m", "venv", venv}},
|
||||
{Label: "Upgrade pip, setuptools, wheel", Args: []string{pip, "install", "--upgrade", "pip", "setuptools", "wheel"}},
|
||||
}
|
||||
|
||||
if fileExists(reqFile) {
|
||||
steps = append(steps, CmdStep{
|
||||
Label: "Install Python packages from requirements.txt",
|
||||
Args: []string{pip, "install", "-r", reqFile},
|
||||
})
|
||||
}
|
||||
|
||||
return steps
|
||||
}
|
||||
|
||||
func buildNpmSteps(autarchDir string) []CmdStep {
|
||||
steps := []CmdStep{
|
||||
{Label: "Install npm packages", Args: []string{"npm", "install"}, Dir: autarchDir},
|
||||
}
|
||||
|
||||
if fileExists(autarchDir + "/scripts/build-hw-libs.sh") {
|
||||
steps = append(steps, CmdStep{
|
||||
Label: "Build hardware JS bundles",
|
||||
Args: []string{"bash", "scripts/build-hw-libs.sh"},
|
||||
Dir: autarchDir,
|
||||
})
|
||||
}
|
||||
|
||||
return steps
|
||||
}
|
||||
|
||||
// filterPackages returns only packages from wanted that exist in available.
|
||||
func filterPackages(available, wanted []string) []string {
|
||||
avail := make(map[string]bool)
|
||||
for _, p := range available {
|
||||
avail[p] = true
|
||||
}
|
||||
var result []string
|
||||
for _, p := range wanted {
|
||||
if avail[p] {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
761
services/server-manager/internal/tui/view_dns.go
Normal file
761
services/server-manager/internal/tui/view_dns.go
Normal file
@@ -0,0 +1,761 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────
|
||||
|
||||
func (a App) renderDNSMenu() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(styleTitle.Render("DNS SERVER"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
|
||||
// Check DNS server status
|
||||
_, dnsRunning := getServiceStatus("autarch-dns")
|
||||
if dnsRunning {
|
||||
b.WriteString(" " + styleStatusOK.Render("● DNS Server is running"))
|
||||
} else {
|
||||
b.WriteString(" " + styleStatusBad.Render("○ DNS Server is stopped"))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Check if binary exists
|
||||
dir := findAutarchDir()
|
||||
binaryPath := dir + "/services/dns-server/autarch-dns"
|
||||
if fileExists(binaryPath) {
|
||||
b.WriteString(" " + styleSuccess.Render("✔ Binary found: ") + styleDim.Render(binaryPath))
|
||||
} else {
|
||||
b.WriteString(" " + styleWarning.Render("⚠ Binary not found — build required"))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Check if source exists
|
||||
sourcePath := dir + "/services/dns-server/main.go"
|
||||
if fileExists(sourcePath) {
|
||||
b.WriteString(" " + styleSuccess.Render("✔ Source code present"))
|
||||
} else {
|
||||
b.WriteString(" " + styleError.Render("✘ Source not found at " + dir + "/services/dns-server/"))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleKey.Render(" [b]") + " Build DNS server from source\n")
|
||||
b.WriteString(styleKey.Render(" [s]") + " Start / Stop DNS server\n")
|
||||
b.WriteString(styleKey.Render(" [m]") + " Manage DNS (zones, records, hosts, blocklist)\n")
|
||||
b.WriteString(styleKey.Render(" [c]") + " Edit DNS config\n")
|
||||
b.WriteString(styleKey.Render(" [t]") + " Test DNS resolution\n")
|
||||
b.WriteString(styleKey.Render(" [l]") + " View DNS logs\n")
|
||||
b.WriteString(styleKey.Render(" [i]") + " Install systemd service unit\n")
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleDim.Render(" esc back"))
|
||||
b.WriteString("\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (a App) renderDNSManageMenu() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(styleTitle.Render("DNS MANAGEMENT"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
|
||||
// Try to get status from API
|
||||
status := getDNSAPIStatus()
|
||||
if status != nil {
|
||||
b.WriteString(" " + styleKey.Render("Queries: ") +
|
||||
lipgloss.NewStyle().Foreground(colorWhite).Render(fmt.Sprintf("%v", status["total_queries"])))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(" " + styleKey.Render("Cache: ") +
|
||||
lipgloss.NewStyle().Foreground(colorWhite).Render(fmt.Sprintf("hits=%v misses=%v", status["cache_hits"], status["cache_misses"])))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(" " + styleKey.Render("Blocked: ") +
|
||||
lipgloss.NewStyle().Foreground(colorWhite).Render(fmt.Sprintf("%v", status["blocked_queries"])))
|
||||
b.WriteString("\n\n")
|
||||
} else {
|
||||
b.WriteString(styleWarning.Render(" ⚠ Cannot reach DNS API — is the server running?"))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleKey.Render(" [z]") + " Manage zones\n")
|
||||
b.WriteString(styleKey.Render(" [h]") + " Manage hosts file\n")
|
||||
b.WriteString(styleKey.Render(" [b]") + " Manage blocklist\n")
|
||||
b.WriteString(styleKey.Render(" [f]") + " Manage forwarding rules\n")
|
||||
b.WriteString(styleKey.Render(" [c]") + " Flush cache\n")
|
||||
b.WriteString(styleKey.Render(" [q]") + " Query log\n")
|
||||
b.WriteString(styleKey.Render(" [t]") + " Top domains\n")
|
||||
b.WriteString(styleKey.Render(" [e]") + " Encryption settings (DoT/DoH)\n")
|
||||
b.WriteString(styleKey.Render(" [r]") + " Root server check\n")
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleDim.Render(" esc back"))
|
||||
b.WriteString("\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (a App) renderDNSZones() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(styleTitle.Render("DNS ZONES"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
|
||||
if len(a.listItems) == 0 {
|
||||
b.WriteString(styleDim.Render(" No zones configured (or API unreachable)."))
|
||||
b.WriteString("\n\n")
|
||||
} else {
|
||||
for i, item := range a.listItems {
|
||||
cursor := " "
|
||||
if i == a.cursor {
|
||||
cursor = styleSelected.Render(" ▸") + " "
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("%s%s %s\n",
|
||||
cursor,
|
||||
lipgloss.NewStyle().Foreground(colorWhite).Bold(true).Render(item.Name),
|
||||
styleDim.Render(item.Status),
|
||||
))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString(styleKey.Render(" [n]") + " New zone ")
|
||||
b.WriteString(styleKey.Render("[enter]") + " View records ")
|
||||
b.WriteString(styleKey.Render("[d]") + " Delete zone\n")
|
||||
b.WriteString(styleDim.Render(" esc back"))
|
||||
b.WriteString("\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Key Handling ────────────────────────────────────────────────────
|
||||
|
||||
func (a App) handleDNSMenu(key string) (tea.Model, tea.Cmd) {
|
||||
switch key {
|
||||
case "b":
|
||||
return a.buildDNSServer()
|
||||
case "s":
|
||||
return a.toggleDNSService()
|
||||
case "m":
|
||||
a.pushView(ViewDNSManage)
|
||||
return a, nil
|
||||
case "c":
|
||||
return a.editDNSConfig()
|
||||
case "t":
|
||||
return a.testDNSResolution()
|
||||
case "l":
|
||||
return a.viewDNSLogs()
|
||||
case "i":
|
||||
return a.installDNSUnit()
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a App) handleDNSManageMenu(key string) (tea.Model, tea.Cmd) {
|
||||
switch key {
|
||||
case "z":
|
||||
return a.loadDNSZones()
|
||||
case "h":
|
||||
return a.manageDNSHosts()
|
||||
case "b":
|
||||
return a.manageDNSBlocklist()
|
||||
case "f":
|
||||
return a.manageDNSForwarding()
|
||||
case "c":
|
||||
return a.flushDNSCache()
|
||||
case "q":
|
||||
return a.viewDNSQueryLog()
|
||||
case "t":
|
||||
return a.viewDNSTopDomains()
|
||||
case "e":
|
||||
return a.viewDNSEncryption()
|
||||
case "r":
|
||||
return a.dnsRootCheck()
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a App) handleDNSZonesMenu(key string) (tea.Model, tea.Cmd) {
|
||||
switch key {
|
||||
case "n":
|
||||
return a.openDNSZoneForm()
|
||||
case "enter":
|
||||
if a.cursor >= 0 && a.cursor < len(a.listItems) {
|
||||
return a.viewDNSZoneRecords(a.listItems[a.cursor].Name)
|
||||
}
|
||||
case "d":
|
||||
if a.cursor >= 0 && a.cursor < len(a.listItems) {
|
||||
zone := a.listItems[a.cursor].Name
|
||||
a.confirmPrompt = fmt.Sprintf("Delete zone '%s' and all its records?", zone)
|
||||
a.confirmAction = func() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return dnsAPIDelete("/api/zones/" + zone)
|
||||
}
|
||||
}
|
||||
a.pushView(ViewConfirm)
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ── DNS Commands ────────────────────────────────────────────────────
|
||||
|
||||
func (a App) buildDNSServer() (App, tea.Cmd) {
|
||||
a.pushView(ViewDNSBuild)
|
||||
a.outputLines = nil
|
||||
a.outputDone = false
|
||||
a.progressStep = 0
|
||||
a.progressTotal = 0
|
||||
|
||||
ch := make(chan tea.Msg, 256)
|
||||
a.outputCh = ch
|
||||
|
||||
go func() {
|
||||
dir := findAutarchDir()
|
||||
dnsDir := dir + "/services/dns-server"
|
||||
|
||||
steps := []CmdStep{
|
||||
{Label: "Download Go dependencies", Args: []string{"go", "mod", "download"}, Dir: dnsDir},
|
||||
{Label: "Build DNS server binary", Args: []string{"go", "build", "-o", "autarch-dns", "."}, Dir: dnsDir},
|
||||
}
|
||||
|
||||
streamSteps(ch, steps)
|
||||
}()
|
||||
|
||||
return a, a.waitForOutput()
|
||||
}
|
||||
|
||||
func (a App) toggleDNSService() (App, tea.Cmd) {
|
||||
return a.toggleService(1) // Index 1 = autarch-dns
|
||||
}
|
||||
|
||||
func (a App) editDNSConfig() (App, tea.Cmd) {
|
||||
// Load DNS config as a settings section
|
||||
dir := findAutarchDir()
|
||||
configPath := dir + "/data/dns/config.json"
|
||||
|
||||
data, err := readFileBytes(configPath)
|
||||
if err != nil {
|
||||
return a, func() tea.Msg {
|
||||
return ResultMsg{
|
||||
Title: "DNS Config",
|
||||
Lines: []string{
|
||||
"No DNS config file found at: " + configPath,
|
||||
"",
|
||||
"Start the DNS server once to generate a default config,",
|
||||
"or build and run: ./autarch-dns --config " + configPath,
|
||||
},
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cfg map[string]interface{}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return a, func() tea.Msg {
|
||||
return ResultMsg{Title: "Error", Lines: []string{"Invalid JSON: " + err.Error()}, IsError: true}
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten to key=value for editing
|
||||
var keys []string
|
||||
var vals []string
|
||||
for k, v := range cfg {
|
||||
keys = append(keys, k)
|
||||
vals = append(vals, fmt.Sprintf("%v", v))
|
||||
}
|
||||
|
||||
a.settingsSection = "dns-config"
|
||||
a.settingsKeys = keys
|
||||
a.settingsVals = vals
|
||||
a.pushView(ViewSettingsSection)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a App) testDNSResolution() (App, tea.Cmd) {
|
||||
return a, func() tea.Msg {
|
||||
domains := []string{"google.com", "github.com", "cloudflare.com"}
|
||||
var lines []string
|
||||
|
||||
for _, domain := range domains {
|
||||
out, err := exec.Command("dig", "@127.0.0.1", domain, "+short", "+time=2").Output()
|
||||
if err != nil {
|
||||
lines = append(lines, styleError.Render(fmt.Sprintf(" ✘ %s: %s", domain, err.Error())))
|
||||
} else {
|
||||
result := strings.TrimSpace(string(out))
|
||||
if result == "" {
|
||||
lines = append(lines, styleWarning.Render(fmt.Sprintf(" ⚠ %s: no answer", domain)))
|
||||
} else {
|
||||
lines = append(lines, styleSuccess.Render(fmt.Sprintf(" ✔ %s → %s", domain, result)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ResultMsg{Title: "DNS Resolution Test (@127.0.0.1)", Lines: lines}
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) viewDNSLogs() (App, tea.Cmd) {
|
||||
return a, func() tea.Msg {
|
||||
out, _ := exec.Command("journalctl", "-u", "autarch-dns", "-n", "30", "--no-pager").Output()
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
return ResultMsg{Title: "DNS Server Logs (last 30)", Lines: lines}
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) installDNSUnit() (App, tea.Cmd) {
|
||||
// Delegate to the service installer for just the DNS unit
|
||||
return a, func() tea.Msg {
|
||||
dir := findAutarchDir()
|
||||
unit := fmt.Sprintf(`[Unit]
|
||||
Description=AUTARCH DNS Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=%s
|
||||
ExecStart=%s/services/dns-server/autarch-dns --config %s/data/dns/config.json
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`, dir, dir, dir)
|
||||
|
||||
path := "/etc/systemd/system/autarch-dns.service"
|
||||
if err := writeFileAtomic(path, []byte(unit)); err != nil {
|
||||
return ResultMsg{Title: "Error", Lines: []string{err.Error()}, IsError: true}
|
||||
}
|
||||
exec.Command("systemctl", "daemon-reload").Run()
|
||||
|
||||
return ResultMsg{
|
||||
Title: "DNS Service Unit Installed",
|
||||
Lines: []string{
|
||||
"Installed: " + path,
|
||||
"",
|
||||
"Start with: systemctl start autarch-dns",
|
||||
"Enable on boot: systemctl enable autarch-dns",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── DNS API Management Commands ─────────────────────────────────────
|
||||
|
||||
func (a App) loadDNSZones() (App, tea.Cmd) {
|
||||
a.pushView(ViewDNSZones)
|
||||
|
||||
return a, func() tea.Msg {
|
||||
zones := dnsAPIGet("/api/zones")
|
||||
if zones == nil {
|
||||
return dnsZonesMsg{items: nil}
|
||||
}
|
||||
|
||||
zoneList, ok := zones.([]interface{})
|
||||
if !ok {
|
||||
return dnsZonesMsg{items: nil}
|
||||
}
|
||||
|
||||
var items []ListItem
|
||||
for _, z := range zoneList {
|
||||
zMap, ok := z.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name := fmt.Sprintf("%v", zMap["domain"])
|
||||
recordCount := 0
|
||||
if records, ok := zMap["records"].([]interface{}); ok {
|
||||
recordCount = len(records)
|
||||
}
|
||||
items = append(items, ListItem{
|
||||
Name: name,
|
||||
Status: fmt.Sprintf("%d records", recordCount),
|
||||
})
|
||||
}
|
||||
return dnsZonesMsg{items: items}
|
||||
}
|
||||
}
|
||||
|
||||
type dnsZonesMsg struct{ items []ListItem }
|
||||
|
||||
func (a App) openDNSZoneForm() (App, tea.Cmd) {
|
||||
a.labels = []string{"Domain", "Primary NS", "Admin Email", "Default TTL"}
|
||||
a.inputs = make([]textinput.Model, 4)
|
||||
|
||||
defaults := []string{"", "ns1.example.com", "admin.example.com", "3600"}
|
||||
for i := range a.inputs {
|
||||
ti := textinput.New()
|
||||
ti.CharLimit = 256
|
||||
ti.Width = 40
|
||||
ti.SetValue(defaults[i])
|
||||
if i == 0 {
|
||||
ti.Focus()
|
||||
ti.SetValue("")
|
||||
}
|
||||
a.inputs[i] = ti
|
||||
}
|
||||
a.focusIdx = 0
|
||||
a.pushView(ViewDNSZoneEdit)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a App) submitDNSZone() (App, tea.Cmd) {
|
||||
domain := a.inputs[0].Value()
|
||||
ns := a.inputs[1].Value()
|
||||
admin := a.inputs[2].Value()
|
||||
ttl := a.inputs[3].Value()
|
||||
|
||||
if domain == "" {
|
||||
return a, func() tea.Msg {
|
||||
return ResultMsg{Title: "Error", Lines: []string{"Domain cannot be empty."}, IsError: true}
|
||||
}
|
||||
}
|
||||
|
||||
a.popView()
|
||||
|
||||
return a, func() tea.Msg {
|
||||
body := fmt.Sprintf(`{"domain":"%s","soa":{"primary_ns":"%s","admin_email":"%s","ttl":%s}}`,
|
||||
domain, ns, admin, ttl)
|
||||
return dnsAPIPost("/api/zones", body)
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) viewDNSZoneRecords(zone string) (App, tea.Cmd) {
|
||||
return a, func() tea.Msg {
|
||||
data := dnsAPIGet("/api/zones/" + zone + "/records")
|
||||
if data == nil {
|
||||
return ResultMsg{Title: "Zone: " + zone, Lines: []string{"No records or API unreachable."}, IsError: true}
|
||||
}
|
||||
|
||||
records, ok := data.([]interface{})
|
||||
if !ok {
|
||||
return ResultMsg{Title: "Zone: " + zone, Lines: []string{"Unexpected response format."}, IsError: true}
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("Zone: %s — %d records", zone, len(records)))
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, styleDim.Render(fmt.Sprintf(" %-8s %-30s %-6s %s", "TYPE", "NAME", "TTL", "VALUE")))
|
||||
lines = append(lines, styleDim.Render(" "+strings.Repeat("─", 70)))
|
||||
|
||||
for _, r := range records {
|
||||
rec, ok := r.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" %-8v %-30v %-6v %v",
|
||||
rec["type"], rec["name"], rec["ttl"], rec["value"]))
|
||||
}
|
||||
|
||||
return ResultMsg{Title: "Zone: " + zone, Lines: lines}
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) manageDNSHosts() (App, tea.Cmd) {
|
||||
return a, func() tea.Msg {
|
||||
data := dnsAPIGet("/api/hosts")
|
||||
if data == nil {
|
||||
return ResultMsg{Title: "DNS Hosts", Lines: []string{"API unreachable."}, IsError: true}
|
||||
}
|
||||
|
||||
hosts, ok := data.([]interface{})
|
||||
if !ok {
|
||||
return ResultMsg{Title: "DNS Hosts", Lines: []string{"No hosts entries."}}
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("%d host entries", len(hosts)))
|
||||
lines = append(lines, "")
|
||||
for _, h := range hosts {
|
||||
hMap, _ := h.(map[string]interface{})
|
||||
lines = append(lines, fmt.Sprintf(" %-16v %v", hMap["ip"], hMap["hostname"]))
|
||||
}
|
||||
return ResultMsg{Title: "DNS Hosts", Lines: lines}
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) manageDNSBlocklist() (App, tea.Cmd) {
|
||||
return a, func() tea.Msg {
|
||||
data := dnsAPIGet("/api/blocklist")
|
||||
if data == nil {
|
||||
return ResultMsg{Title: "DNS Blocklist", Lines: []string{"API unreachable."}, IsError: true}
|
||||
}
|
||||
|
||||
bl, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
return ResultMsg{Title: "DNS Blocklist", Lines: []string{"Unexpected format."}}
|
||||
}
|
||||
|
||||
var lines []string
|
||||
if domains, ok := bl["domains"].([]interface{}); ok {
|
||||
lines = append(lines, fmt.Sprintf("%d blocked domains", len(domains)))
|
||||
lines = append(lines, "")
|
||||
max := 30
|
||||
if len(domains) < max {
|
||||
max = len(domains)
|
||||
}
|
||||
for _, d := range domains[:max] {
|
||||
lines = append(lines, " "+fmt.Sprintf("%v", d))
|
||||
}
|
||||
if len(domains) > 30 {
|
||||
lines = append(lines, styleDim.Render(fmt.Sprintf(" ... and %d more", len(domains)-30)))
|
||||
}
|
||||
} else {
|
||||
lines = append(lines, "Blocklist is empty.")
|
||||
}
|
||||
|
||||
return ResultMsg{Title: "DNS Blocklist", Lines: lines}
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) manageDNSForwarding() (App, tea.Cmd) {
|
||||
return a, func() tea.Msg {
|
||||
data := dnsAPIGet("/api/forwarding")
|
||||
if data == nil {
|
||||
return ResultMsg{Title: "DNS Forwarding", Lines: []string{"API unreachable."}, IsError: true}
|
||||
}
|
||||
|
||||
rules, ok := data.([]interface{})
|
||||
if !ok {
|
||||
return ResultMsg{Title: "DNS Forwarding", Lines: []string{"No forwarding rules configured."}}
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("%d forwarding rules", len(rules)))
|
||||
lines = append(lines, "")
|
||||
for _, r := range rules {
|
||||
rMap, _ := r.(map[string]interface{})
|
||||
lines = append(lines, fmt.Sprintf(" %v → %v", rMap["zone"], rMap["upstream"]))
|
||||
}
|
||||
return ResultMsg{Title: "DNS Forwarding Rules", Lines: lines}
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) flushDNSCache() (App, tea.Cmd) {
|
||||
return a, func() tea.Msg {
|
||||
return dnsAPIDelete("/api/cache")
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) viewDNSQueryLog() (App, tea.Cmd) {
|
||||
return a, func() tea.Msg {
|
||||
data := dnsAPIGet("/api/querylog?limit=30")
|
||||
if data == nil {
|
||||
return ResultMsg{Title: "DNS Query Log", Lines: []string{"API unreachable."}, IsError: true}
|
||||
}
|
||||
|
||||
entries, ok := data.([]interface{})
|
||||
if !ok {
|
||||
return ResultMsg{Title: "DNS Query Log", Lines: []string{"No entries."}}
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, e := range entries {
|
||||
eMap, _ := e.(map[string]interface{})
|
||||
lines = append(lines, fmt.Sprintf(" %-20v %-6v %-30v %v",
|
||||
eMap["time"], eMap["type"], eMap["name"], eMap["client"]))
|
||||
}
|
||||
return ResultMsg{Title: "DNS Query Log (last 30)", Lines: lines}
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) viewDNSTopDomains() (App, tea.Cmd) {
|
||||
return a, func() tea.Msg {
|
||||
data := dnsAPIGet("/api/stats/top-domains?limit=20")
|
||||
if data == nil {
|
||||
return ResultMsg{Title: "Top Domains", Lines: []string{"API unreachable."}, IsError: true}
|
||||
}
|
||||
|
||||
domains, ok := data.([]interface{})
|
||||
if !ok {
|
||||
return ResultMsg{Title: "Top Domains", Lines: []string{"No data."}}
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for i, d := range domains {
|
||||
dMap, _ := d.(map[string]interface{})
|
||||
lines = append(lines, fmt.Sprintf(" %2d. %-40v %v queries", i+1, dMap["domain"], dMap["count"]))
|
||||
}
|
||||
return ResultMsg{Title: "Top 20 Queried Domains", Lines: lines}
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) viewDNSEncryption() (App, tea.Cmd) {
|
||||
return a, func() tea.Msg {
|
||||
data := dnsAPIGet("/api/encryption")
|
||||
if data == nil {
|
||||
return ResultMsg{Title: "DNS Encryption", Lines: []string{"API unreachable."}, IsError: true}
|
||||
}
|
||||
|
||||
enc, _ := data.(map[string]interface{})
|
||||
var lines []string
|
||||
for k, v := range enc {
|
||||
status := styleStatusBad.Render("disabled")
|
||||
if v == true {
|
||||
status = styleStatusOK.Render("enabled")
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" %-20s %s", k, status))
|
||||
}
|
||||
return ResultMsg{Title: "DNS Encryption Status", Lines: lines}
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) dnsRootCheck() (App, tea.Cmd) {
|
||||
return a, func() tea.Msg {
|
||||
body := dnsAPIPostRaw("/api/rootcheck", "")
|
||||
if body == nil {
|
||||
return ResultMsg{Title: "Root Check", Lines: []string{"API unreachable."}, IsError: true}
|
||||
}
|
||||
|
||||
results, ok := body.([]interface{})
|
||||
if !ok {
|
||||
return ResultMsg{Title: "Root Check", Lines: []string{"Unexpected format."}}
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, r := range results {
|
||||
rMap, _ := r.(map[string]interface{})
|
||||
latency := fmt.Sprintf("%v", rMap["latency"])
|
||||
status := styleSuccess.Render("✔")
|
||||
if rMap["error"] != nil && rMap["error"] != "" {
|
||||
status = styleError.Render("✘")
|
||||
latency = fmt.Sprintf("%v", rMap["error"])
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" %s %-20v %s", status, rMap["server"], latency))
|
||||
}
|
||||
return ResultMsg{Title: "Root Server Latency Check", Lines: lines}
|
||||
}
|
||||
}
|
||||
|
||||
// ── DNS API Helpers ─────────────────────────────────────────────────
|
||||
|
||||
func getDNSAPIBase() string {
|
||||
return "http://127.0.0.1:5380"
|
||||
}
|
||||
|
||||
func getDNSAPIToken() string {
|
||||
dir := findAutarchDir()
|
||||
configPath := dir + "/data/dns/config.json"
|
||||
data, err := readFileBytes(configPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var cfg map[string]interface{}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return ""
|
||||
}
|
||||
if token, ok := cfg["api_token"].(string); ok {
|
||||
return token
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getDNSAPIStatus() map[string]interface{} {
|
||||
data := dnsAPIGet("/api/metrics")
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
m, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func dnsAPIGet(path string) interface{} {
|
||||
url := getDNSAPIBase() + path
|
||||
token := getDNSAPIToken()
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
return result
|
||||
}
|
||||
|
||||
func dnsAPIPost(path, body string) tea.Msg {
|
||||
result := dnsAPIPostRaw(path, body)
|
||||
if result == nil {
|
||||
return ResultMsg{Title: "Error", Lines: []string{"API request failed."}, IsError: true}
|
||||
}
|
||||
return ResultMsg{Title: "Success", Lines: []string{"Operation completed."}}
|
||||
}
|
||||
|
||||
func dnsAPIPostRaw(path, body string) interface{} {
|
||||
url := getDNSAPIBase() + path
|
||||
token := getDNSAPIToken()
|
||||
|
||||
req, err := http.NewRequest("POST", url, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
return result
|
||||
}
|
||||
|
||||
func dnsAPIDelete(path string) tea.Msg {
|
||||
url := getDNSAPIBase() + path
|
||||
token := getDNSAPIToken()
|
||||
|
||||
req, err := http.NewRequest("DELETE", url, nil)
|
||||
if err != nil {
|
||||
return ResultMsg{Title: "Error", Lines: []string{err.Error()}, IsError: true}
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return ResultMsg{Title: "Error", Lines: []string{err.Error()}, IsError: true}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return ResultMsg{Title: "Success", Lines: []string{"Deleted."}}
|
||||
}
|
||||
52
services/server-manager/internal/tui/view_main.go
Normal file
52
services/server-manager/internal/tui/view_main.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package tui
|
||||
|
||||
import tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
func (a App) handleMainMenu(key string) (tea.Model, tea.Cmd) {
|
||||
// Number key shortcut
|
||||
for _, item := range a.mainMenu {
|
||||
if key == item.Key {
|
||||
if item.Key == "q" {
|
||||
return a, tea.Quit
|
||||
}
|
||||
return a.navigateToView(item.View)
|
||||
}
|
||||
}
|
||||
|
||||
// Enter on selected item
|
||||
if key == "enter" {
|
||||
if a.cursor >= 0 && a.cursor < len(a.mainMenu) {
|
||||
item := a.mainMenu[a.cursor]
|
||||
if item.Key == "q" {
|
||||
return a, tea.Quit
|
||||
}
|
||||
return a.navigateToView(item.View)
|
||||
}
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a App) navigateToView(v ViewID) (tea.Model, tea.Cmd) {
|
||||
a.pushView(v)
|
||||
|
||||
switch v {
|
||||
case ViewDeploy:
|
||||
// Static menu, no async loading
|
||||
case ViewDeps:
|
||||
// Load dependency status
|
||||
return a, a.loadDepsStatus()
|
||||
case ViewModules:
|
||||
return a, a.loadModules()
|
||||
case ViewSettings:
|
||||
return a, a.loadSettings()
|
||||
case ViewUsers:
|
||||
// Static menu, no loading
|
||||
case ViewService:
|
||||
return a, a.loadServiceStatus()
|
||||
case ViewDNS:
|
||||
// Static menu
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
273
services/server-manager/internal/tui/view_modules.go
Normal file
273
services/server-manager/internal/tui/view_modules.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// ── Module Categories ───────────────────────────────────────────────
|
||||
|
||||
var moduleCategories = map[string]string{
|
||||
"defender.py": "Defense",
|
||||
"defender_monitor.py": "Defense",
|
||||
"defender_windows.py": "Defense",
|
||||
"container_sec.py": "Defense",
|
||||
"msf.py": "Offense",
|
||||
"exploit_dev.py": "Offense",
|
||||
"loadtest.py": "Offense",
|
||||
"phishmail.py": "Offense",
|
||||
"deauth.py": "Offense",
|
||||
"mitm_proxy.py": "Offense",
|
||||
"c2_framework.py": "Offense",
|
||||
"api_fuzzer.py": "Offense",
|
||||
"webapp_scanner.py": "Offense",
|
||||
"cloud_scan.py": "Offense",
|
||||
"starlink_hack.py": "Offense",
|
||||
"rcs_tools.py": "Offense",
|
||||
"sms_forge.py": "Offense",
|
||||
"pineapple.py": "Offense",
|
||||
"password_toolkit.py": "Offense",
|
||||
"counter.py": "Counter",
|
||||
"anti_forensics.py": "Counter",
|
||||
"analyze.py": "Analysis",
|
||||
"forensics.py": "Analysis",
|
||||
"llm_trainer.py": "Analysis",
|
||||
"report_engine.py": "Analysis",
|
||||
"threat_intel.py": "Analysis",
|
||||
"ble_scanner.py": "Analysis",
|
||||
"rfid_tools.py": "Analysis",
|
||||
"reverse_eng.py": "Analysis",
|
||||
"steganography.py": "Analysis",
|
||||
"incident_resp.py": "Analysis",
|
||||
"net_mapper.py": "Analysis",
|
||||
"log_correlator.py": "Analysis",
|
||||
"malware_sandbox.py": "Analysis",
|
||||
"email_sec.py": "Analysis",
|
||||
"vulnerab_scanner.py": "Analysis",
|
||||
"recon.py": "OSINT",
|
||||
"dossier.py": "OSINT",
|
||||
"geoip.py": "OSINT",
|
||||
"adultscan.py": "OSINT",
|
||||
"yandex_osint.py": "OSINT",
|
||||
"social_eng.py": "OSINT",
|
||||
"ipcapture.py": "OSINT",
|
||||
"snoop_decoder.py": "OSINT",
|
||||
"simulate.py": "Simulate",
|
||||
"android_apps.py": "Android",
|
||||
"android_advanced.py": "Android",
|
||||
"android_boot.py": "Android",
|
||||
"android_payload.py": "Android",
|
||||
"android_protect.py": "Android",
|
||||
"android_recon.py": "Android",
|
||||
"android_root.py": "Android",
|
||||
"android_screen.py": "Android",
|
||||
"android_sms.py": "Android",
|
||||
"hardware_local.py": "Hardware",
|
||||
"hardware_remote.py": "Hardware",
|
||||
"iphone_local.py": "Hardware",
|
||||
"wireshark.py": "Hardware",
|
||||
"sdr_tools.py": "Hardware",
|
||||
"upnp_manager.py": "System",
|
||||
"wireguard_manager.py": "System",
|
||||
"revshell.py": "System",
|
||||
"hack_hijack.py": "System",
|
||||
"chat.py": "Core",
|
||||
"agent.py": "Core",
|
||||
"agent_hal.py": "Core",
|
||||
"mysystem.py": "Core",
|
||||
"setup.py": "Core",
|
||||
"workflow.py": "Core",
|
||||
"nettest.py": "Core",
|
||||
"rsf.py": "Core",
|
||||
"ad_audit.py": "Offense",
|
||||
"router_sploit.py": "Offense",
|
||||
"wifi_audit.py": "Offense",
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────
|
||||
|
||||
func (a App) renderModulesList() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(styleTitle.Render("MODULES"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
|
||||
if len(a.listItems) == 0 {
|
||||
b.WriteString(styleDim.Render(" Loading..."))
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Group by category
|
||||
groups := make(map[string][]int)
|
||||
for i, item := range a.listItems {
|
||||
groups[item.Extra] = append(groups[item.Extra], i)
|
||||
}
|
||||
|
||||
// Sort category names
|
||||
var cats []string
|
||||
for c := range groups {
|
||||
cats = append(cats, c)
|
||||
}
|
||||
sort.Strings(cats)
|
||||
|
||||
for _, cat := range cats {
|
||||
b.WriteString(styleKey.Render(fmt.Sprintf(" ── %s ", cat)))
|
||||
b.WriteString(styleDim.Render(fmt.Sprintf("(%d)", len(groups[cat]))))
|
||||
b.WriteString("\n")
|
||||
|
||||
for _, idx := range groups[cat] {
|
||||
item := a.listItems[idx]
|
||||
|
||||
cursor := " "
|
||||
if idx == a.cursor {
|
||||
cursor = styleSelected.Render(" ▸") + " "
|
||||
}
|
||||
|
||||
status := styleStatusOK.Render("●")
|
||||
if !item.Enabled {
|
||||
status = styleStatusBad.Render("○")
|
||||
}
|
||||
|
||||
name := item.Name
|
||||
if idx == a.cursor {
|
||||
name = lipgloss.NewStyle().Foreground(colorWhite).Bold(true).Render(name)
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("%s%s %s\n", cursor, status, name))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString(styleKey.Render(" [enter]") + " Toggle enabled/disabled ")
|
||||
b.WriteString(styleKey.Render("[r]") + " Refresh\n")
|
||||
b.WriteString(styleDim.Render(" esc back"))
|
||||
b.WriteString("\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Key Handling ────────────────────────────────────────────────────
|
||||
|
||||
func (a App) handleModulesMenu(key string) (tea.Model, tea.Cmd) {
|
||||
switch key {
|
||||
case "enter":
|
||||
if a.cursor >= 0 && a.cursor < len(a.listItems) {
|
||||
return a.toggleModule(a.cursor)
|
||||
}
|
||||
case "r":
|
||||
return a, a.loadModules()
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a App) handleModuleToggle(key string) (tea.Model, tea.Cmd) {
|
||||
return a.handleModulesMenu(key)
|
||||
}
|
||||
|
||||
// ── Commands ────────────────────────────────────────────────────────
|
||||
|
||||
func (a App) loadModules() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
dir := findAutarchDir()
|
||||
modulesDir := filepath.Join(dir, "modules")
|
||||
|
||||
entries, err := os.ReadDir(modulesDir)
|
||||
if err != nil {
|
||||
return ResultMsg{
|
||||
Title: "Error",
|
||||
Lines: []string{"Cannot read modules directory: " + err.Error()},
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
var items []ListItem
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".py") || name == "__init__.py" {
|
||||
continue
|
||||
}
|
||||
|
||||
cat := "Other"
|
||||
if c, ok := moduleCategories[name]; ok {
|
||||
cat = c
|
||||
}
|
||||
|
||||
// Check if module has a run() function (basic check)
|
||||
content, _ := os.ReadFile(filepath.Join(modulesDir, name))
|
||||
hasRun := strings.Contains(string(content), "def run(")
|
||||
|
||||
items = append(items, ListItem{
|
||||
Name: strings.TrimSuffix(name, ".py"),
|
||||
Enabled: hasRun,
|
||||
Extra: cat,
|
||||
Status: name,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by category then name
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].Extra != items[j].Extra {
|
||||
return items[i].Extra < items[j].Extra
|
||||
}
|
||||
return items[i].Name < items[j].Name
|
||||
})
|
||||
|
||||
return modulesLoadedMsg{items: items}
|
||||
}
|
||||
}
|
||||
|
||||
type modulesLoadedMsg struct{ items []ListItem }
|
||||
|
||||
func (a App) toggleModule(idx int) (App, tea.Cmd) {
|
||||
if idx < 0 || idx >= len(a.listItems) {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
item := a.listItems[idx]
|
||||
dir := findAutarchDir()
|
||||
modulesDir := filepath.Join(dir, "modules")
|
||||
disabledDir := filepath.Join(modulesDir, "disabled")
|
||||
|
||||
srcFile := filepath.Join(modulesDir, item.Status)
|
||||
dstFile := filepath.Join(disabledDir, item.Status)
|
||||
|
||||
if item.Enabled {
|
||||
// Disable: move to disabled/
|
||||
os.MkdirAll(disabledDir, 0755)
|
||||
if err := os.Rename(srcFile, dstFile); err != nil {
|
||||
return a, func() tea.Msg {
|
||||
return ResultMsg{
|
||||
Title: "Error",
|
||||
Lines: []string{"Cannot disable module: " + err.Error()},
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
a.listItems[idx].Enabled = false
|
||||
} else {
|
||||
// Enable: move from disabled/ back
|
||||
if err := os.Rename(dstFile, srcFile); err != nil {
|
||||
// It might just be a module without run()
|
||||
return a, func() tea.Msg {
|
||||
return ResultMsg{
|
||||
Title: "Note",
|
||||
Lines: []string{"Module " + item.Name + " is present but has no run() entry point."},
|
||||
IsError: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
a.listItems[idx].Enabled = true
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
380
services/server-manager/internal/tui/view_service.go
Normal file
380
services/server-manager/internal/tui/view_service.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// ── Service Definitions ─────────────────────────────────────────────
|
||||
|
||||
type serviceInfo struct {
|
||||
Name string
|
||||
Unit string // systemd unit name
|
||||
Desc string
|
||||
Binary string // path to check
|
||||
}
|
||||
|
||||
var managedServices = []serviceInfo{
|
||||
{"AUTARCH Web", "autarch-web", "Web dashboard (Flask)", "autarch_web.py"},
|
||||
{"AUTARCH DNS", "autarch-dns", "DNS server (Go)", "autarch-dns"},
|
||||
{"AUTARCH Autonomy", "autarch-autonomy", "Autonomous AI daemon", ""},
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────
|
||||
|
||||
func (a App) renderServiceMenu() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(styleTitle.Render("SERVICE MANAGEMENT"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
|
||||
// Show service statuses — checks both systemd and raw processes
|
||||
svcChecks := []struct {
|
||||
Info serviceInfo
|
||||
Process string // process name to pgrep for
|
||||
}{
|
||||
{managedServices[0], "autarch_web.py"},
|
||||
{managedServices[1], "autarch-dns"},
|
||||
{managedServices[2], "autonomy"},
|
||||
}
|
||||
|
||||
for _, sc := range svcChecks {
|
||||
status, running := getProcessStatus(sc.Info.Unit, sc.Process)
|
||||
|
||||
indicator := styleStatusOK.Render("● running")
|
||||
if !running {
|
||||
indicator = styleStatusBad.Render("○ stopped")
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf(" %s %s\n",
|
||||
indicator,
|
||||
lipgloss.NewStyle().Foreground(colorWhite).Bold(true).Render(sc.Info.Name),
|
||||
))
|
||||
b.WriteString(fmt.Sprintf(" %s %s\n",
|
||||
styleDim.Render(sc.Info.Desc),
|
||||
styleDim.Render("("+status+")"),
|
||||
))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleKey.Render(" [1]") + " Start/Stop AUTARCH Web\n")
|
||||
b.WriteString(styleKey.Render(" [2]") + " Start/Stop AUTARCH DNS\n")
|
||||
b.WriteString(styleKey.Render(" [3]") + " Start/Stop Autonomy Daemon\n")
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleKey.Render(" [r]") + " Restart all running services\n")
|
||||
b.WriteString(styleKey.Render(" [e]") + " Enable all services on boot\n")
|
||||
b.WriteString(styleKey.Render(" [i]") + " Install/update systemd unit files\n")
|
||||
b.WriteString(styleKey.Render(" [l]") + " View service logs (journalctl)\n")
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleDim.Render(" esc back"))
|
||||
b.WriteString("\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Key Handling ────────────────────────────────────────────────────
|
||||
|
||||
func (a App) handleServiceMenu(key string) (tea.Model, tea.Cmd) {
|
||||
switch key {
|
||||
case "1":
|
||||
return a.toggleService(0)
|
||||
case "2":
|
||||
return a.toggleService(1)
|
||||
case "3":
|
||||
return a.toggleService(2)
|
||||
case "r":
|
||||
return a.restartAllServices()
|
||||
case "e":
|
||||
return a.enableAllServices()
|
||||
case "i":
|
||||
return a.installServiceUnits()
|
||||
case "l":
|
||||
return a.viewServiceLogs()
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ── Commands ────────────────────────────────────────────────────────
|
||||
|
||||
func (a App) loadServiceStatus() tea.Cmd {
|
||||
return nil // Services are checked live in render
|
||||
}
|
||||
|
||||
func getServiceStatus(unit string) (string, bool) {
|
||||
out, err := exec.Command("systemctl", "is-active", unit).Output()
|
||||
status := strings.TrimSpace(string(out))
|
||||
if err != nil || status != "active" {
|
||||
// Check if unit exists
|
||||
_, existErr := exec.Command("systemctl", "cat", unit).Output()
|
||||
if existErr != nil {
|
||||
return "not installed", false
|
||||
}
|
||||
return status, false
|
||||
}
|
||||
return status, true
|
||||
}
|
||||
|
||||
// getProcessStatus checks both systemd and direct process for a service.
|
||||
// Returns (status description, isRunning).
|
||||
func getProcessStatus(unitName, processName string) (string, bool) {
|
||||
// First try systemd
|
||||
status, running := getServiceStatus(unitName)
|
||||
if running {
|
||||
return "systemd: " + status, true
|
||||
}
|
||||
|
||||
// Fall back to process detection (pgrep)
|
||||
out, err := exec.Command("pgrep", "-f", processName).Output()
|
||||
if err == nil && strings.TrimSpace(string(out)) != "" {
|
||||
pids := strings.Fields(strings.TrimSpace(string(out)))
|
||||
return fmt.Sprintf("running (PID %s)", pids[0]), true
|
||||
}
|
||||
|
||||
return status, false
|
||||
}
|
||||
|
||||
func (a App) toggleService(idx int) (App, tea.Cmd) {
|
||||
if idx < 0 || idx >= len(managedServices) {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
svc := managedServices[idx]
|
||||
processNames := []string{"autarch_web.py", "autarch-dns", "autonomy"}
|
||||
procName := processNames[idx]
|
||||
|
||||
_, sysRunning := getServiceStatus(svc.Unit)
|
||||
_, procRunning := getProcessStatus(svc.Unit, procName)
|
||||
isRunning := sysRunning || procRunning
|
||||
|
||||
return a, func() tea.Msg {
|
||||
dir := findAutarchDir()
|
||||
|
||||
if isRunning {
|
||||
// Stop — try systemd first, then kill process
|
||||
if sysRunning {
|
||||
cmd := exec.Command("systemctl", "stop", svc.Unit)
|
||||
cmd.CombinedOutput()
|
||||
}
|
||||
// Also kill any direct processes
|
||||
exec.Command("pkill", "-f", procName).Run()
|
||||
return ResultMsg{
|
||||
Title: "Service " + svc.Name,
|
||||
Lines: []string{svc.Name + " stopped."},
|
||||
}
|
||||
}
|
||||
|
||||
// Start — try systemd first, fall back to direct launch
|
||||
if _, err := exec.Command("systemctl", "cat", svc.Unit).Output(); err == nil {
|
||||
cmd := exec.Command("systemctl", "start", svc.Unit)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return ResultMsg{
|
||||
Title: "Service Error",
|
||||
Lines: []string{"systemctl start failed:", string(out), err.Error(), "", "Trying direct launch..."},
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
return ResultMsg{
|
||||
Title: "Service " + svc.Name,
|
||||
Lines: []string{svc.Name + " started via systemd."},
|
||||
}
|
||||
}
|
||||
|
||||
// Direct launch (no systemd unit installed)
|
||||
var startCmd *exec.Cmd
|
||||
switch idx {
|
||||
case 0: // Web
|
||||
venvPy := dir + "/venv/bin/python3"
|
||||
startCmd = exec.Command(venvPy, dir+"/autarch_web.py")
|
||||
case 1: // DNS
|
||||
binary := dir + "/services/dns-server/autarch-dns"
|
||||
configFile := dir + "/data/dns/config.json"
|
||||
startCmd = exec.Command(binary, "--config", configFile)
|
||||
case 2: // Autonomy
|
||||
venvPy := dir + "/venv/bin/python3"
|
||||
startCmd = exec.Command(venvPy, "-c",
|
||||
"import sys; sys.path.insert(0,'"+dir+"'); from core.autonomy import AutonomyDaemon; AutonomyDaemon().run()")
|
||||
}
|
||||
|
||||
if startCmd != nil {
|
||||
startCmd.Dir = dir
|
||||
// Detach process so it survives manager exit
|
||||
startCmd.Stdout = nil
|
||||
startCmd.Stderr = nil
|
||||
if err := startCmd.Start(); err != nil {
|
||||
return ResultMsg{
|
||||
Title: "Service Error",
|
||||
Lines: []string{"Failed to start " + svc.Name + ":", err.Error()},
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
// Release so it runs independently
|
||||
go startCmd.Wait()
|
||||
|
||||
return ResultMsg{
|
||||
Title: "Service " + svc.Name,
|
||||
Lines: []string{
|
||||
svc.Name + " started directly (PID " + fmt.Sprintf("%d", startCmd.Process.Pid) + ").",
|
||||
"",
|
||||
styleDim.Render("Tip: Install systemd units with [i] for persistent service management."),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return ResultMsg{
|
||||
Title: "Error",
|
||||
Lines: []string{"No start method available for " + svc.Name},
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) restartAllServices() (App, tea.Cmd) {
|
||||
return a, func() tea.Msg {
|
||||
var lines []string
|
||||
for _, svc := range managedServices {
|
||||
_, running := getServiceStatus(svc.Unit)
|
||||
if running {
|
||||
cmd := exec.Command("systemctl", "restart", svc.Unit)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
lines = append(lines, styleError.Render("✘ "+svc.Name+": "+strings.TrimSpace(string(out))))
|
||||
} else {
|
||||
lines = append(lines, styleSuccess.Render("✔ "+svc.Name+": restarted"))
|
||||
}
|
||||
} else {
|
||||
lines = append(lines, styleDim.Render("- "+svc.Name+": not running, skipped"))
|
||||
}
|
||||
}
|
||||
return ResultMsg{Title: "Restart Services", Lines: lines}
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) enableAllServices() (App, tea.Cmd) {
|
||||
return a, func() tea.Msg {
|
||||
var lines []string
|
||||
for _, svc := range managedServices {
|
||||
cmd := exec.Command("systemctl", "enable", svc.Unit)
|
||||
_, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
lines = append(lines, styleWarning.Render("⚠ "+svc.Name+": could not enable (unit may not exist)"))
|
||||
} else {
|
||||
lines = append(lines, styleSuccess.Render("✔ "+svc.Name+": enabled on boot"))
|
||||
}
|
||||
}
|
||||
return ResultMsg{Title: "Enable Services", Lines: lines}
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) installServiceUnits() (App, tea.Cmd) {
|
||||
return a, func() tea.Msg {
|
||||
dir := findAutarchDir()
|
||||
var lines []string
|
||||
|
||||
// Web service unit
|
||||
webUnit := fmt.Sprintf(`[Unit]
|
||||
Description=AUTARCH Web Dashboard
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=%s
|
||||
ExecStart=%s/venv/bin/python3 %s/autarch_web.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`, dir, dir, dir)
|
||||
|
||||
// DNS service unit
|
||||
dnsUnit := fmt.Sprintf(`[Unit]
|
||||
Description=AUTARCH DNS Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=%s
|
||||
ExecStart=%s/services/dns-server/autarch-dns --config %s/data/dns/config.json
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`, dir, dir, dir)
|
||||
|
||||
// Autonomy daemon unit
|
||||
autoUnit := fmt.Sprintf(`[Unit]
|
||||
Description=AUTARCH Autonomy Daemon
|
||||
After=network.target autarch-web.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=%s
|
||||
ExecStart=%s/venv/bin/python3 -c "from core.autonomy import AutonomyDaemon; AutonomyDaemon().run()"
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`, dir, dir)
|
||||
|
||||
units := map[string]string{
|
||||
"autarch-web.service": webUnit,
|
||||
"autarch-dns.service": dnsUnit,
|
||||
"autarch-autonomy.service": autoUnit,
|
||||
}
|
||||
|
||||
for name, content := range units {
|
||||
path := "/etc/systemd/system/" + name
|
||||
if err := writeFileAtomic(path, []byte(content)); err != nil {
|
||||
lines = append(lines, styleError.Render("✘ "+name+": "+err.Error()))
|
||||
} else {
|
||||
lines = append(lines, styleSuccess.Render("✔ "+name+": installed"))
|
||||
}
|
||||
}
|
||||
|
||||
// Reload systemd
|
||||
exec.Command("systemctl", "daemon-reload").Run()
|
||||
lines = append(lines, "", styleSuccess.Render("✔ systemctl daemon-reload"))
|
||||
|
||||
return ResultMsg{Title: "Service Units Installed", Lines: lines}
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) viewServiceLogs() (App, tea.Cmd) {
|
||||
return a, func() tea.Msg {
|
||||
var lines []string
|
||||
for _, svc := range managedServices {
|
||||
out, _ := exec.Command("journalctl", "-u", svc.Unit, "-n", "10", "--no-pager").Output()
|
||||
lines = append(lines, styleKey.Render("── "+svc.Name+" ──"))
|
||||
logLines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
for _, l := range logLines {
|
||||
lines = append(lines, " "+l)
|
||||
}
|
||||
lines = append(lines, "")
|
||||
}
|
||||
return ResultMsg{Title: "Service Logs (last 10 entries)", Lines: lines}
|
||||
}
|
||||
}
|
||||
|
||||
func writeFileAtomic(path string, data []byte) error {
|
||||
tmp := path + ".tmp"
|
||||
if err := writeFile(tmp, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
return renameFile(tmp, path)
|
||||
}
|
||||
249
services/server-manager/internal/tui/view_settings.go
Normal file
249
services/server-manager/internal/tui/view_settings.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/darkhal/autarch-server-manager/internal/config"
|
||||
)
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────
|
||||
|
||||
func (a App) renderSettingsSections() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(styleTitle.Render("SETTINGS — autarch_settings.conf"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
|
||||
if len(a.settingsSections) == 0 {
|
||||
b.WriteString(styleDim.Render(" Loading..."))
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
for i, sec := range a.settingsSections {
|
||||
cursor := " "
|
||||
if i == a.cursor {
|
||||
cursor = styleSelected.Render(" ▸") + " "
|
||||
b.WriteString(cursor + styleKey.Render("["+sec+"]") + "\n")
|
||||
} else {
|
||||
b.WriteString(cursor + styleDim.Render("["+sec+"]") + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString(styleKey.Render(" [enter]") + " Edit section ")
|
||||
b.WriteString(styleDim.Render(" esc back"))
|
||||
b.WriteString("\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (a App) renderSettingsKeys() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(styleTitle.Render(fmt.Sprintf("SETTINGS — [%s]", a.settingsSection)))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
|
||||
for i, key := range a.settingsKeys {
|
||||
val := ""
|
||||
if i < len(a.settingsVals) {
|
||||
val = a.settingsVals[i]
|
||||
}
|
||||
|
||||
cursor := " "
|
||||
if i == a.cursor {
|
||||
cursor = styleSelected.Render(" ▸") + " "
|
||||
}
|
||||
|
||||
// Mask sensitive values
|
||||
displayVal := val
|
||||
if isSensitiveKey(key) && len(val) > 4 {
|
||||
displayVal = val[:4] + strings.Repeat("•", len(val)-4)
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("%s%s = %s\n",
|
||||
cursor,
|
||||
styleKey.Render(key),
|
||||
lipgloss.NewStyle().Foreground(colorWhite).Render(displayVal),
|
||||
))
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString(styleKey.Render(" [enter]") + " Edit all values ")
|
||||
b.WriteString(styleKey.Render("[d]") + " Edit selected ")
|
||||
b.WriteString(styleDim.Render(" esc back"))
|
||||
b.WriteString("\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func isSensitiveKey(key string) bool {
|
||||
k := strings.ToLower(key)
|
||||
return strings.Contains(k, "password") || strings.Contains(k, "secret") ||
|
||||
strings.Contains(k, "api_key") || strings.Contains(k, "token")
|
||||
}
|
||||
|
||||
// ── Key Handling ────────────────────────────────────────────────────
|
||||
|
||||
func (a App) handleSettingsMenu(key string) (tea.Model, tea.Cmd) {
|
||||
switch key {
|
||||
case "enter":
|
||||
if a.cursor >= 0 && a.cursor < len(a.settingsSections) {
|
||||
a.settingsSection = a.settingsSections[a.cursor]
|
||||
return a.loadSettingsSection()
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a App) handleSettingsSection(key string) (tea.Model, tea.Cmd) {
|
||||
switch key {
|
||||
case "enter":
|
||||
// Edit all values in this section
|
||||
return a.openSettingsEdit()
|
||||
case "d":
|
||||
// Edit single selected value
|
||||
if a.cursor >= 0 && a.cursor < len(a.settingsKeys) {
|
||||
return a.openSingleSettingEdit(a.cursor)
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ── Commands ────────────────────────────────────────────────────────
|
||||
|
||||
func (a App) loadSettings() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
confPath := findAutarchDir() + "/autarch_settings.conf"
|
||||
sections, err := config.ListSections(confPath)
|
||||
if err != nil {
|
||||
return ResultMsg{
|
||||
Title: "Error",
|
||||
Lines: []string{"Cannot read config: " + err.Error()},
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
sort.Strings(sections)
|
||||
return settingsLoadedMsg{sections: sections}
|
||||
}
|
||||
}
|
||||
|
||||
type settingsLoadedMsg struct{ sections []string }
|
||||
|
||||
func (a App) loadSettingsSection() (App, tea.Cmd) {
|
||||
confPath := findAutarchDir() + "/autarch_settings.conf"
|
||||
keys, vals, err := config.GetSection(confPath, a.settingsSection)
|
||||
if err != nil {
|
||||
return a, func() tea.Msg {
|
||||
return ResultMsg{
|
||||
Title: "Error",
|
||||
Lines: []string{err.Error()},
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.settingsKeys = keys
|
||||
a.settingsVals = vals
|
||||
a.pushView(ViewSettingsSection)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a App) openSettingsEdit() (App, tea.Cmd) {
|
||||
a.labels = make([]string, len(a.settingsKeys))
|
||||
a.inputs = make([]textinput.Model, len(a.settingsKeys))
|
||||
copy(a.labels, a.settingsKeys)
|
||||
|
||||
for i, val := range a.settingsVals {
|
||||
ti := textinput.New()
|
||||
ti.CharLimit = 512
|
||||
ti.Width = 50
|
||||
ti.SetValue(val)
|
||||
if isSensitiveKey(a.settingsKeys[i]) {
|
||||
ti.EchoMode = textinput.EchoPassword
|
||||
}
|
||||
if i == 0 {
|
||||
ti.Focus()
|
||||
}
|
||||
a.inputs[i] = ti
|
||||
}
|
||||
|
||||
a.focusIdx = 0
|
||||
a.pushView(ViewSettingsEdit)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a App) openSingleSettingEdit(idx int) (App, tea.Cmd) {
|
||||
a.labels = []string{a.settingsKeys[idx]}
|
||||
a.inputs = make([]textinput.Model, 1)
|
||||
|
||||
ti := textinput.New()
|
||||
ti.CharLimit = 512
|
||||
ti.Width = 50
|
||||
ti.SetValue(a.settingsVals[idx])
|
||||
if isSensitiveKey(a.settingsKeys[idx]) {
|
||||
ti.EchoMode = textinput.EchoPassword
|
||||
}
|
||||
ti.Focus()
|
||||
a.inputs[0] = ti
|
||||
a.focusIdx = 0
|
||||
a.pushView(ViewSettingsEdit)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a App) saveSettings() (App, tea.Cmd) {
|
||||
confPath := findAutarchDir() + "/autarch_settings.conf"
|
||||
|
||||
// Read the full config file
|
||||
data, err := os.ReadFile(confPath)
|
||||
if err != nil {
|
||||
return a, func() tea.Msg {
|
||||
return ResultMsg{Title: "Error", Lines: []string{err.Error()}, IsError: true}
|
||||
}
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
|
||||
// Apply changes
|
||||
for i, label := range a.labels {
|
||||
newVal := a.inputs[i].Value()
|
||||
content = config.SetValue(content, a.settingsSection, label, newVal)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(confPath, []byte(content), 0644); err != nil {
|
||||
return a, func() tea.Msg {
|
||||
return ResultMsg{Title: "Error", Lines: []string{err.Error()}, IsError: true}
|
||||
}
|
||||
}
|
||||
|
||||
a.popView()
|
||||
|
||||
// Reload the section
|
||||
keys, vals, _ := config.GetSection(confPath, a.settingsSection)
|
||||
a.settingsKeys = keys
|
||||
a.settingsVals = vals
|
||||
|
||||
return a, func() tea.Msg {
|
||||
return ResultMsg{
|
||||
Title: "Settings Saved",
|
||||
Lines: []string{
|
||||
fmt.Sprintf("Updated [%s] section with %d values.", a.settingsSection, len(a.labels)),
|
||||
"",
|
||||
"Restart AUTARCH services for changes to take effect.",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
225
services/server-manager/internal/tui/view_users.go
Normal file
225
services/server-manager/internal/tui/view_users.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/darkhal/autarch-server-manager/internal/users"
|
||||
)
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────
|
||||
|
||||
func (a App) renderUsersMenu() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(styleTitle.Render("USER MANAGEMENT"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
|
||||
// Show current credentials info
|
||||
dir := findAutarchDir()
|
||||
creds, err := users.LoadCredentials(dir)
|
||||
if err != nil {
|
||||
b.WriteString(styleWarning.Render(" No credentials file found — using defaults (admin/admin)"))
|
||||
b.WriteString("\n\n")
|
||||
} else {
|
||||
b.WriteString(" " + styleKey.Render("Current user: ") +
|
||||
lipgloss.NewStyle().Foreground(colorWhite).Bold(true).Render(creds.Username))
|
||||
b.WriteString("\n")
|
||||
if creds.ForceChange {
|
||||
b.WriteString(" " + styleWarning.Render("⚠ Password change required on next login"))
|
||||
} else {
|
||||
b.WriteString(" " + styleSuccess.Render("✔ Password is set"))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleKey.Render(" [c]") + " Create new user / change username\n")
|
||||
b.WriteString(styleKey.Render(" [r]") + " Reset password\n")
|
||||
b.WriteString(styleKey.Render(" [f]") + " Force password change on next login\n")
|
||||
b.WriteString(styleKey.Render(" [d]") + " Reset to defaults (admin/admin)\n")
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleDim.Render(" esc back"))
|
||||
b.WriteString("\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Key Handling ────────────────────────────────────────────────────
|
||||
|
||||
func (a App) handleUsersMenu(key string) (tea.Model, tea.Cmd) {
|
||||
switch key {
|
||||
case "c":
|
||||
return a.openUserCreateForm()
|
||||
case "r":
|
||||
return a.openUserResetForm()
|
||||
case "f":
|
||||
return a.forcePasswordChange()
|
||||
case "d":
|
||||
a.confirmPrompt = "Reset credentials to admin/admin? This cannot be undone."
|
||||
a.confirmAction = func() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
dir := findAutarchDir()
|
||||
err := users.ResetToDefaults(dir)
|
||||
if err != nil {
|
||||
return ResultMsg{Title: "Error", Lines: []string{err.Error()}, IsError: true}
|
||||
}
|
||||
return ResultMsg{
|
||||
Title: "Credentials Reset",
|
||||
Lines: []string{
|
||||
"Username: admin",
|
||||
"Password: admin",
|
||||
"",
|
||||
"Force change on next login: YES",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
a.pushView(ViewConfirm)
|
||||
return a, nil
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ── Forms ───────────────────────────────────────────────────────────
|
||||
|
||||
func (a App) openUserCreateForm() (App, tea.Cmd) {
|
||||
a.labels = []string{"Username", "Password", "Confirm Password"}
|
||||
a.inputs = make([]textinput.Model, 3)
|
||||
|
||||
for i := range a.inputs {
|
||||
ti := textinput.New()
|
||||
ti.CharLimit = 128
|
||||
ti.Width = 40
|
||||
if i > 0 {
|
||||
ti.EchoMode = textinput.EchoPassword
|
||||
}
|
||||
if i == 0 {
|
||||
ti.Focus()
|
||||
}
|
||||
a.inputs[i] = ti
|
||||
}
|
||||
|
||||
a.focusIdx = 0
|
||||
a.pushView(ViewUsersCreate)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a App) openUserResetForm() (App, tea.Cmd) {
|
||||
a.labels = []string{"New Password", "Confirm Password"}
|
||||
a.inputs = make([]textinput.Model, 2)
|
||||
|
||||
for i := range a.inputs {
|
||||
ti := textinput.New()
|
||||
ti.CharLimit = 128
|
||||
ti.Width = 40
|
||||
ti.EchoMode = textinput.EchoPassword
|
||||
if i == 0 {
|
||||
ti.Focus()
|
||||
}
|
||||
a.inputs[i] = ti
|
||||
}
|
||||
|
||||
a.focusIdx = 0
|
||||
a.pushView(ViewUsersReset)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a App) submitUserCreate() (App, tea.Cmd) {
|
||||
username := a.inputs[0].Value()
|
||||
password := a.inputs[1].Value()
|
||||
confirm := a.inputs[2].Value()
|
||||
|
||||
if username == "" {
|
||||
return a, func() tea.Msg {
|
||||
return ResultMsg{Title: "Error", Lines: []string{"Username cannot be empty."}, IsError: true}
|
||||
}
|
||||
}
|
||||
if len(password) < 4 {
|
||||
return a, func() tea.Msg {
|
||||
return ResultMsg{Title: "Error", Lines: []string{"Password must be at least 4 characters."}, IsError: true}
|
||||
}
|
||||
}
|
||||
if password != confirm {
|
||||
return a, func() tea.Msg {
|
||||
return ResultMsg{Title: "Error", Lines: []string{"Passwords do not match."}, IsError: true}
|
||||
}
|
||||
}
|
||||
|
||||
dir := findAutarchDir()
|
||||
err := users.CreateUser(dir, username, password)
|
||||
a.popView()
|
||||
|
||||
if err != nil {
|
||||
return a, func() tea.Msg {
|
||||
return ResultMsg{Title: "Error", Lines: []string{err.Error()}, IsError: true}
|
||||
}
|
||||
}
|
||||
|
||||
return a, func() tea.Msg {
|
||||
return ResultMsg{
|
||||
Title: "User Created",
|
||||
Lines: []string{
|
||||
"Username: " + username,
|
||||
"Password: (set)",
|
||||
"",
|
||||
"Restart the web dashboard for changes to take effect.",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) submitUserReset() (App, tea.Cmd) {
|
||||
password := a.inputs[0].Value()
|
||||
confirm := a.inputs[1].Value()
|
||||
|
||||
if len(password) < 4 {
|
||||
return a, func() tea.Msg {
|
||||
return ResultMsg{Title: "Error", Lines: []string{"Password must be at least 4 characters."}, IsError: true}
|
||||
}
|
||||
}
|
||||
if password != confirm {
|
||||
return a, func() tea.Msg {
|
||||
return ResultMsg{Title: "Error", Lines: []string{"Passwords do not match."}, IsError: true}
|
||||
}
|
||||
}
|
||||
|
||||
dir := findAutarchDir()
|
||||
err := users.ResetPassword(dir, password)
|
||||
a.popView()
|
||||
|
||||
if err != nil {
|
||||
return a, func() tea.Msg {
|
||||
return ResultMsg{Title: "Error", Lines: []string{err.Error()}, IsError: true}
|
||||
}
|
||||
}
|
||||
|
||||
return a, func() tea.Msg {
|
||||
return ResultMsg{
|
||||
Title: "Password Reset",
|
||||
Lines: []string{"Password has been updated.", "", "Force change on next login: NO"},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) forcePasswordChange() (App, tea.Cmd) {
|
||||
dir := findAutarchDir()
|
||||
err := users.SetForceChange(dir, true)
|
||||
if err != nil {
|
||||
return a, func() tea.Msg {
|
||||
return ResultMsg{Title: "Error", Lines: []string{err.Error()}, IsError: true}
|
||||
}
|
||||
}
|
||||
return a, func() tea.Msg {
|
||||
return ResultMsg{
|
||||
Title: "Force Change Enabled",
|
||||
Lines: []string{"User will be required to change password on next login."},
|
||||
}
|
||||
}
|
||||
}
|
||||
114
services/server-manager/internal/users/credentials.go
Normal file
114
services/server-manager/internal/users/credentials.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Package users manages AUTARCH web dashboard credentials.
|
||||
// Credentials are stored in data/web_credentials.json as bcrypt hashes.
|
||||
package users
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Credentials matches the Python web_credentials.json format.
|
||||
type Credentials struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
ForceChange bool `json:"force_change"`
|
||||
}
|
||||
|
||||
func credentialsPath(autarchDir string) string {
|
||||
return filepath.Join(autarchDir, "data", "web_credentials.json")
|
||||
}
|
||||
|
||||
// LoadCredentials reads the current credentials from disk.
|
||||
func LoadCredentials(autarchDir string) (*Credentials, error) {
|
||||
path := credentialsPath(autarchDir)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read credentials: %w", err)
|
||||
}
|
||||
|
||||
var creds Credentials
|
||||
if err := json.Unmarshal(data, &creds); err != nil {
|
||||
return nil, fmt.Errorf("parse credentials: %w", err)
|
||||
}
|
||||
return &creds, nil
|
||||
}
|
||||
|
||||
// SaveCredentials writes credentials to disk.
|
||||
func SaveCredentials(autarchDir string, creds *Credentials) error {
|
||||
path := credentialsPath(autarchDir)
|
||||
|
||||
// Ensure data directory exists
|
||||
os.MkdirAll(filepath.Dir(path), 0755)
|
||||
|
||||
data, err := json.MarshalIndent(creds, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal credentials: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
return fmt.Errorf("write credentials: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateUser creates a new user with bcrypt-hashed password.
|
||||
func CreateUser(autarchDir, username, password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
creds := &Credentials{
|
||||
Username: username,
|
||||
Password: string(hash),
|
||||
ForceChange: false,
|
||||
}
|
||||
return SaveCredentials(autarchDir, creds)
|
||||
}
|
||||
|
||||
// ResetPassword changes the password for the existing user.
|
||||
func ResetPassword(autarchDir, newPassword string) error {
|
||||
creds, err := LoadCredentials(autarchDir)
|
||||
if err != nil {
|
||||
// If no file exists, create with default username
|
||||
creds = &Credentials{Username: "admin"}
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
creds.Password = string(hash)
|
||||
creds.ForceChange = false
|
||||
return SaveCredentials(autarchDir, creds)
|
||||
}
|
||||
|
||||
// SetForceChange sets the force_change flag.
|
||||
func SetForceChange(autarchDir string, force bool) error {
|
||||
creds, err := LoadCredentials(autarchDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
creds.ForceChange = force
|
||||
return SaveCredentials(autarchDir, creds)
|
||||
}
|
||||
|
||||
// ResetToDefaults resets credentials to admin/admin with force change.
|
||||
func ResetToDefaults(autarchDir string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
creds := &Credentials{
|
||||
Username: "admin",
|
||||
Password: string(hash),
|
||||
ForceChange: true,
|
||||
}
|
||||
return SaveCredentials(autarchDir, creds)
|
||||
}
|
||||
Reference in New Issue
Block a user