feat: add motd-assist TUI for managing dynamic MOTDs

Initial Go project built with Bubble Tea and Lip Gloss:
- Live preview and management of /etc/update-motd.d scripts
- Embedded presets (ubuntu-default, minimal, server-dashboard)
- Bundled POSIX sh style scripts (brand header, sysinfo panel, footer)
- Snapshot/restore safety around applying changes
- CLI subcommands preview, apply, restore, list-presets
- Unit tests for detect, sanitize, and apply logic
This commit is contained in:
Tronax 2026-08-24 14:19:28 +02:00
commit d56659e21a
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
34 changed files with 3260 additions and 0 deletions

74
internal/motd/detect.go Normal file
View file

@ -0,0 +1,74 @@
package motd
import (
"os"
"path/filepath"
"strings"
)
// HostInfo beschreibt den erkannten MOTD-Zustand des Systems.
type HostInfo struct {
Hostname string
Distro string // PRETTY_NAME aus /etc/os-release
PAM bool // pam_motd konfiguriert (dynamischer MOTD aktiv)
PAMWhere []string // Dateien in /etc/pam.d mit pam_motd-Eintrag
DynamicPath string // vorhandene Cache-Datei der dynamischen MOTD ("" = keine)
StaticMOTD bool // /etc/motd vorhanden und nicht leer
}
// DetectHost sammelt Host- und MOTD-Informationen unter root.
// Fehler einzelner Erkennungen führen nicht zum Abbruch.
func DetectHost(root string) HostInfo {
info := HostInfo{Hostname: "unbekannt", Distro: "unbekannt"}
if h, err := os.Hostname(); err == nil && h != "" {
info.Hostname = strings.SplitN(h, ".", 2)[0]
}
if pretty, ok := osReleaseValue(filepath.Join(root, "etc", "os-release"), "PRETTY_NAME"); ok {
info.Distro = pretty
}
for _, svc := range []string{"sshd", "login", "remote"} {
b, err := os.ReadFile(filepath.Join(root, "etc", "pam.d", svc))
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.Contains(line, "pam_motd") {
info.PAM = true
info.PAMWhere = append(info.PAMWhere, svc)
break
}
}
}
for _, cand := range []string{"run/motd.dynamic", "run/motd"} {
p := filepath.Join(root, cand)
if fi, err := os.Stat(p); err == nil && !fi.IsDir() && fi.Size() > 0 {
info.DynamicPath = p
break
}
}
if fi, err := os.Stat(filepath.Join(root, "etc", "motd")); err == nil && !fi.IsDir() && fi.Size() > 0 {
info.StaticMOTD = true
}
return info
}
func osReleaseValue(path, key string) (string, bool) {
b, err := os.ReadFile(path)
if err != nil {
return "", false
}
for _, line := range strings.Split(string(b), "\n") {
if v, ok := strings.CutPrefix(strings.TrimSpace(line), key+"="); ok {
return strings.Trim(v, `"'`), true
}
}
return "", false
}