No One Can Stop Me Now

This commit is contained in:
DigiJ
2026-03-13 23:48:47 -07:00
parent 4d3570781e
commit 1a138a2bd0
428 changed files with 519668 additions and 259 deletions

Binary file not shown.

Binary file not shown.

View 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 "══════════════════════════════════════════════════════"

Binary file not shown.

View 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)
}
}

View 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
)

View 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=

View 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)
}

View 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
}

View 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"
}

View 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)
}

View 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

View 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)
}
}

View 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
}

View 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."}}
}

View 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
}

View 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
}

View 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)
}

View 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.",
},
}
}
}

View 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."},
}
}
}

View 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)
}