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

344
internal/motd/apply.go Normal file
View file

@ -0,0 +1,344 @@
package motd
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"motd-assist/internal/assets"
)
const (
modeEnabled os.FileMode = 0o755
modeDisabled os.FileMode = 0o644
)
// Validate prüft einen Plan gegen das Dateisystem, bevor Root-Operationen
// ausgeführt werden. Alle Namen müssen einfach bleiben und im
// update-motd.d-Verzeichnis liegen; fremde Dateien sind tabu.
func Validate(root string, plan Plan) error {
dir := MotdDir(root)
if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
return fmt.Errorf("%s existiert nicht", dir)
}
existing := make(map[string]bool)
listing, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, e := range listing {
existing[e.Name()] = true
}
for _, op := range plan.Chmod {
if !validName.MatchString(op.Name) {
return fmt.Errorf("ungültiger Name %q", op.Name)
}
if !existing[op.Name] {
return fmt.Errorf("chmod: %s existiert nicht", op.Name)
}
}
for _, op := range plan.Removes {
if !validName.MatchString(op.Name) {
return fmt.Errorf("ungültiger Name %q", op.Name)
}
if !existing[op.Name] {
return fmt.Errorf("remove: %s existiert nicht", op.Name)
}
if !isBundledFile(filepath.Join(dir, op.Name)) {
return fmt.Errorf("remove: %s ist kein motd-assist-Script", op.Name)
}
}
renamedAway := make(map[string]bool, len(plan.Renames))
for _, op := range plan.Renames {
if !validName.MatchString(op.From) || !validName.MatchString(op.To) {
return fmt.Errorf("ungültiger Name in Umbenennung %q → %q", op.From, op.To)
}
if !existing[op.From] {
return fmt.Errorf("rename: %s existiert nicht", op.From)
}
renamedAway[op.From] = true
}
// Zielnamen dürfen nur frei werden, wenn die Datei dort wegrename'd oder
// entfernt wird sonst Kollision.
for _, op := range plan.Renames {
if existing[op.To] && !renamedAway[op.To] {
isRemoved := false
for _, r := range plan.Removes {
if r.Name == op.To {
isRemoved = true
break
}
}
if !isRemoved {
return fmt.Errorf("rename: Ziel %s ist belegt", op.To)
}
}
}
for _, op := range plan.Installs {
if !validName.MatchString(op.Name) {
return fmt.Errorf("ungültiger Name %q", op.Name)
}
if _, ok := assets.ScriptContent(op.Source); !ok {
return fmt.Errorf("install: unbekannte Vorlage %q", op.Source)
}
if existing[op.Name] && !isBundledFile(filepath.Join(dir, op.Name)) {
return fmt.Errorf("install: %s wäre von einem fremden Script überschrieben", op.Name)
}
}
return nil
}
// isBundledFile meldet, ob die Datei unter path einer gebündelten Vorlage
// entspricht (oder nicht lesbar ist, dann false).
func isBundledFile(path string) bool {
b, err := os.ReadFile(path)
if err != nil {
return false
}
_, ok := assets.MatchScriptBytes(b)
return ok
}
// Snapshot sichert den Zustand vor einem Apply, damit Restore möglich ist.
type Snapshot struct {
SavedAt time.Time `json:"saved_at"`
Scripts []SnapshotScript `json:"scripts"`
}
// SnapshotScript hält Name, Modus und (falls gebündelt) die Vorlage fest.
type SnapshotScript struct {
Name string `json:"name"`
Mode uint32 `json:"mode"`
Bundled bool `json:"bundled"`
Source string `json:"source,omitempty"`
}
// SnapshotPath liefert den Ablageort des letzten Snapshots.
func SnapshotPath(root string) string {
return filepath.Join(root, "var", "lib", "motd-assist", "last-snapshot.json")
}
// WriteFile schreibt den Plan als temporäre JSON-Datei, z.B. für die
// Übergabe an "motd-assist apply-state" unter sudo.
func (p Plan) WriteFile() (string, error) {
b, err := json.Marshal(p)
if err != nil {
return "", err
}
f, err := os.CreateTemp("", "motd-assist-plan-*.json")
if err != nil {
return "", err
}
defer f.Close()
if _, err := f.Write(b); err != nil {
os.Remove(f.Name())
return "", err
}
return f.Name(), nil
}
// ReadPlanFile liest eine mit WriteFile erzeugte Plandatei.
func ReadPlanFile(path string) (Plan, error) {
var plan Plan
b, err := os.ReadFile(path)
if err != nil {
return plan, err
}
if err := json.Unmarshal(b, &plan); err != nil {
return plan, fmt.Errorf("ungültige Plandatei: %w", err)
}
return plan, nil
}
func writeSnapshot(root string) error {
scripts, err := Discover(root)
if err != nil {
return err
}
snap := Snapshot{SavedAt: time.Now(), Scripts: make([]SnapshotScript, 0, len(scripts))}
for _, s := range scripts {
ss := SnapshotScript{Name: s.Name, Bundled: s.Bundled}
if fi, err := os.Stat(s.Path); err == nil {
ss.Mode = uint32(fi.Mode().Perm())
}
if s.Bundled {
if b, err := os.ReadFile(s.Path); err == nil {
if src, ok := assets.MatchScript(string(b)); ok {
ss.Source = src
}
}
}
snap.Scripts = append(snap.Scripts, ss)
}
path := SnapshotPath(root)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
b, err := json.MarshalIndent(snap, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(b, '\n'), 0o644)
}
// Execute führt einen validierten Plan aus: Snapshot → Removes →
// Umbenennungen (zweiphasig gegen Kollisionen) → Installationen → Modi.
// Erfordert Root, wenn /etc/update-motd.d Root gehört.
func Execute(root string, plan Plan) error {
if err := Validate(root, plan); err != nil {
return err
}
if err := writeSnapshot(root); err != nil {
return fmt.Errorf("Snapshot fehlgeschlagen: %w", err)
}
dir := MotdDir(root)
// Reihenfolge: Removes → Chmods (alte Namen!) → Renames → Installs.
for _, op := range plan.Removes {
if err := os.Remove(filepath.Join(dir, op.Name)); err != nil {
return err
}
}
for _, op := range plan.Chmod {
mode := modeDisabled
if op.Enable {
mode = modeEnabled
}
if err := os.Chmod(filepath.Join(dir, op.Name), mode); err != nil {
return err
}
}
// Zweiphasige Umbenennung: erst alle nach .tmp-N (Discover ignoriert
// Punktnamen), dann auf die finalen Namen.
if len(plan.Renames) > 0 {
for i := range plan.Renames {
tmp := fmt.Sprintf(".ma-tmp-%d", i)
if err := os.Rename(filepath.Join(dir, plan.Renames[i].From), filepath.Join(dir, tmp)); err != nil {
return err
}
plan.Renames[i].From = tmp
}
for _, op := range plan.Renames {
if err := os.Rename(filepath.Join(dir, op.From), filepath.Join(dir, op.To)); err != nil {
return err
}
}
}
for _, op := range plan.Installs {
content, _ := assets.ScriptContent(op.Source)
mode := modeDisabled
if op.Enabled {
mode = modeEnabled
}
if err := os.WriteFile(filepath.Join(dir, op.Name), []byte(content), mode); err != nil {
return err
}
}
return nil
}
// Restore setzt den letzten Snapshot bestmöglich zurück (Modi, Namen,
// gebündelte Installationen). Best-effort: Fehler werden gesammelt.
func Restore(root string) error {
b, err := os.ReadFile(SnapshotPath(root))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return errors.New("kein Snapshot vorhanden")
}
return err
}
var snap Snapshot
if err := json.Unmarshal(b, &snap); err != nil {
return err
}
current, err := Discover(root)
if err != nil {
return err
}
curBySuffix := make(map[string]Script, len(current))
for _, c := range current {
curBySuffix[suffixOf(c.Name)] = c
}
snapBySuffix := make(map[string]SnapshotScript, len(snap.Scripts))
for _, s := range snap.Scripts {
snapBySuffix[suffixOf(s.Name)] = s
}
var errs []error
dir := MotdDir(root)
// Dateien entfernen, die es vor dem Apply nicht gab (nur unsere).
for _, c := range current {
if _, ok := snapBySuffix[suffixOf(c.Name)]; !ok && c.Bundled {
if err := os.Remove(c.Path); err != nil {
errs = append(errs, err)
}
}
}
// Fehlende gebündelte Scripte aus der Vorlage nachinstallieren.
var renames []RenameOp
for _, s := range snap.Scripts {
if _, ok := curBySuffix[suffixOf(s.Name)]; ok {
continue
}
if s.Bundled && s.Source != "" {
content, ok := assets.ScriptContent(s.Source)
if !ok {
errs = append(errs, fmt.Errorf("Vorlage %s fehlt für %s", s.Source, s.Name))
continue
}
if err := os.WriteFile(filepath.Join(dir, s.Name), []byte(content), os.FileMode(s.Mode)); err != nil {
errs = append(errs, err)
}
}
}
// Namen und Modi wiederherstellen.
for _, c := range current {
s, ok := snapBySuffix[suffixOf(c.Name)]
if !ok {
continue
}
if c.Name != s.Name {
renames = append(renames, RenameOp{From: c.Name, To: s.Name})
}
if err := os.Chmod(c.Path, os.FileMode(s.Mode)); err != nil {
errs = append(errs, err)
}
}
if len(renames) > 0 {
for i := range renames {
tmp := fmt.Sprintf(".ma-tmp-%d", i)
if err := os.Rename(filepath.Join(dir, renames[i].From), filepath.Join(dir, tmp)); err != nil {
errs = append(errs, err)
continue
}
renames[i].From = tmp
}
for _, op := range renames {
if op.From == op.To {
continue
}
if err := os.Rename(filepath.Join(dir, op.From), filepath.Join(dir, op.To)); err != nil {
errs = append(errs, err)
}
}
}
if len(errs) > 0 {
return fmt.Errorf("Restore mit Fehlern: %w", errors.Join(errs...))
}
return nil
}

View file

@ -0,0 +1,73 @@
package motd
import (
"os"
"path/filepath"
"testing"
)
// setupDir legt ein update-motd.d-Verzeichnis unter einem Temp-Root an.
func setupDir(t *testing.T, files map[string]string) string {
t.Helper()
root := t.TempDir()
dir := MotdDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
for name, content := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o755); err != nil {
t.Fatal(err)
}
}
return root
}
func TestValidateRejectsUnknownNames(t *testing.T) {
root := setupDir(t, map[string]string{"00-header": "#!/bin/sh\necho hi\n"})
err := Validate(root, Plan{Chmod: []ChmodOp{{Name: "../evil", Enable: true}}})
if err == nil {
t.Error("Pfad-Traversal sollte abgelehnt werden")
}
err = Validate(root, Plan{Chmod: []ChmodOp{{Name: "gibt-es-nicht", Enable: true}}})
if err == nil {
t.Error("chmod auf fehlende Datei sollte abgelehnt werden")
}
}
func TestValidateRejectsForeignOverwrite(t *testing.T) {
root := setupDir(t, map[string]string{"00-header": "#!/bin/sh\necho eigenes\n"})
err := Validate(root, Plan{Installs: []InstallOp{{Name: "00-header", Source: "footer", Enabled: true}}})
if err == nil {
t.Error("Install über fremdes Script sollte abgelehnt werden")
}
}
func TestValidateRejectsForeignRemove(t *testing.T) {
root := setupDir(t, map[string]string{"10-sysinfo": "#!/bin/sh\necho eigenes\n"})
err := Validate(root, Plan{Removes: []RemoveOp{{Name: "10-sysinfo"}}})
if err == nil {
t.Error("Remove eines fremden Scripts sollte abgelehnt werden")
}
}
func TestValidateRenameCollision(t *testing.T) {
root := setupDir(t, map[string]string{
"00-a": "#!/bin/sh\n",
"10-b": "#!/bin/sh\n",
})
// b wird nicht wegbewegt → Ziel 10-b ist belegt.
err := Validate(root, Plan{Renames: []RenameOp{{From: "00-a", To: "10-b"}}})
if err == nil {
t.Error("Kollision sollte abgelehnt werden")
}
// Beide tauschen → zweiphasige Ausführung macht das frei.
err = Validate(root, Plan{Renames: []RenameOp{{From: "00-a", To: "10-b"}, {From: "10-b", To: "00-a"}}})
if err != nil {
t.Errorf("Tausch sollte erlaubt sein: %v", err)
}
}

View file

@ -0,0 +1,118 @@
//go:build unix
package motd
import (
"os"
"path/filepath"
"testing"
"motd-assist/internal/assets"
)
func TestExecuteAppliesPlanAndRestore(t *testing.T) {
root := setupDir(t, map[string]string{
"00-header": "#!/bin/sh\necho header\n",
"10-sysinfo": "#!/bin/sh\necho sysinfo\n",
})
dir := MotdDir(root)
if err := os.Chmod(filepath.Join(dir, "10-sysinfo"), 0o644); err != nil {
t.Fatal(err)
}
current, err := Discover(root)
if err != nil {
t.Fatal(err)
}
// Ziel: sysinfo vor header, sysinfo aktiv, header aus.
desired := []DesiredEntry{
{Name: "10-sysinfo", Enabled: true},
{Name: "00-header", Enabled: false},
}
plan := Diff(current, desired)
if plan.Empty() {
t.Fatal("Plan sollte Änderungen enthalten")
}
if err := Execute(root, plan); err != nil {
t.Fatalf("Execute: %v", err)
}
after, err := Discover(root)
if err != nil {
t.Fatal(err)
}
if len(after) != 2 {
t.Fatalf("nach Execute: %v", after)
}
if after[0].Name != "00-sysinfo" || !after[0].Enabled {
t.Errorf("after[0] = %+v, want 00-sysinfo aktiv", after[0])
}
if after[1].Name != "10-header" || after[1].Enabled {
t.Errorf("after[1] = %+v, want 10-header inaktiv", after[1])
}
if _, err := os.Stat(SnapshotPath(root)); err != nil {
t.Errorf("Snapshot fehlt: %v", err)
}
if err := Restore(root); err != nil {
t.Fatalf("Restore: %v", err)
}
restored, err := Discover(root)
if err != nil {
t.Fatal(err)
}
if restored[0].Name != "00-header" || !restored[0].Enabled {
t.Errorf("restored[0] = %+v, want 00-header aktiv", restored[0])
}
if restored[1].Name != "10-sysinfo" || restored[1].Enabled {
t.Errorf("restored[1] = %+v, want 10-sysinfo inaktiv", restored[1])
}
}
func TestExecuteInstallAndRemove(t *testing.T) {
root := setupDir(t, map[string]string{
"00-header": "#!/bin/sh\necho header\n",
"95-footer": "#!/bin/sh\ntrue\n", // wird unten als gebündelt ersetzt
})
dir := MotdDir(root)
// echtes gebündeltes Script einsetzen, damit Remove validiert
content, ok := assets.ScriptContent("footer")
if !ok {
t.Fatal("footer-Vorlage nicht gefunden")
}
if err := os.WriteFile(filepath.Join(dir, "95-footer"), []byte(content), 0o755); err != nil {
t.Fatal(err)
}
current, err := Discover(root)
if err != nil {
t.Fatal(err)
}
desired := []DesiredEntry{
{Name: "10-brand-header", Enabled: true, Install: "brand-header"},
{Name: "00-header", Enabled: true},
{Name: "95-footer", Remove: true},
}
if err := Execute(root, Diff(current, desired)); err != nil {
t.Fatalf("Execute: %v", err)
}
after, err := Discover(root)
if err != nil {
t.Fatal(err)
}
var names []string
for _, s := range after {
names = append(names, s.Name)
}
want := []string{"00-brand-header", "10-header"}
if len(names) != 2 || names[0] != want[0] || names[1] != want[1] {
t.Errorf("names = %v, want %v", names, want)
}
if !after[0].Bundled {
t.Error("brand-header sollte als gebündelt erkannt werden")
}
if !after[0].Enabled || !after[1].Enabled {
t.Errorf("Modi falsch: %+v", after)
}
}

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
}

View file

@ -0,0 +1,40 @@
package motd
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func TestDiscoverListsValidNamesSorted(t *testing.T) {
root := t.TempDir()
dir := MotdDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"10-sysinfo", "00-header", "notes.txt", ".hidden", "README"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"), 0o644); err != nil {
t.Fatal(err)
}
}
scripts, err := Discover(root)
if err != nil {
t.Fatal(err)
}
var names []string
for _, s := range scripts {
names = append(names, s.Name)
}
// README ist ein valider run-parts-Name und bleibt dabei.
if !reflect.DeepEqual(names, []string{"00-header", "10-sysinfo", "README"}) {
t.Errorf("names = %v", names)
}
}
func TestDiscoverMissingDir(t *testing.T) {
if _, err := Discover(t.TempDir()); err == nil {
t.Error("Fehler erwartet, wenn update-motd.d fehlt")
}
}

107
internal/motd/preview.go Normal file
View file

@ -0,0 +1,107 @@
package motd
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"motd-assist/internal/assets"
)
// scriptTimeout begrenzt die Laufzeit eines einzelnen Preview-Scripts
// (langsame Netzwerk-Scripte wie 50-motd-news blockieren sonst die TUI).
const scriptTimeout = 2 * time.Second
// PreviewScript ist ein Preview-Kandidat: entweder eine vorhandene Datei
// (Path) oder eine noch nicht installierte gebündelte Vorlage (Source).
type PreviewScript struct {
Name string
Path string
Source string
Enabled bool
}
// Render führt die aktivierten Scripte in Reihenfolge aus und liefert den
// bereinigten MOTD-Output. Fehler einzelner Scripte werden als dezente
// Hinweiszeile eingebaut statt die ganze Preview abbrechen.
func Render(scripts []PreviewScript, width int) string {
var parts []string
for _, s := range scripts {
if !s.Enabled {
continue
}
path := s.Path
if path == "" {
if s.Source == "" {
continue
}
staged, cleanup, err := stagePreview(s.Source, s.Name)
if err != nil {
parts = append(parts, dimLine(fmt.Sprintf("%s: %v", s.Name, err)))
continue
}
path = staged
defer cleanup()
}
addPart(&parts, runScript(path, width), width)
}
return strings.Join(parts, "\n\n")
}
func addPart(parts *[]string, raw string, width int) {
if text := strings.TrimSpace(Sanitize(raw, width)); text != "" {
*parts = append(*parts, text)
}
}
func runScript(path string, width int) string {
ctx, cancel := context.WithTimeout(context.Background(), scriptTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, path)
cmd.Env = append(os.Environ(),
fmt.Sprintf("COLUMNS=%d", width),
"TERM=xterm-256color",
)
cmd.Dir = "/"
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return dimLine(fmt.Sprintf("%s: Timeout", filepath.Base(path)))
}
if buf.Len() == 0 {
return dimLine(fmt.Sprintf("%s: nicht ausführbar (%v)", filepath.Base(path), err))
}
// Exit-Status != 0 ist bei MOTD-Scripten üblich; Output behalten.
}
return buf.String()
}
// stagePreview kopiert eine gebündelte Vorlage in ein Temporärverzeichnis,
// damit die Live-Vorschau sie vor der Installation zeigen kann.
func stagePreview(source, name string) (string, func(), error) {
content, ok := assets.ScriptContent(source)
if !ok {
return "", nil, fmt.Errorf("unbekannte Vorlage %q", source)
}
dir, err := os.MkdirTemp("", "motd-assist-")
if err != nil {
return "", nil, err
}
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
os.RemoveAll(dir)
return "", nil, err
}
return path, func() { os.RemoveAll(dir) }, nil
}
func dimLine(s string) string { return "\x1b[2m" + s + "\x1b[0m" }

View file

@ -0,0 +1,6 @@
//go:build !unix
package motd
// IsRoot ist auf Nicht-Unix-Plattformen immer false (Zielsystem ist Linux).
func IsRoot() bool { return false }

View file

@ -0,0 +1,8 @@
//go:build unix
package motd
import "os"
// IsRoot meldet, ob der Prozess mit EUID 0 läuft.
func IsRoot() bool { return os.Geteuid() == 0 }

120
internal/motd/sanitize.go Normal file
View file

@ -0,0 +1,120 @@
package motd
import (
"strings"
"github.com/mattn/go-runewidth"
)
type escapeKind int
const (
escNone escapeKind = iota
escSGR
)
// parseEscape klassifiziert eine Escape-Sequenz ab runes[0] == ESC und
// liefert die konsumierte Rune-Anzahl. Nur SGR (Farben) ist relevant.
func parseEscape(r []rune) (string, int, escapeKind) {
if len(r) < 2 {
return "", len(r), escNone
}
switch r[1] {
case '[': // CSI: Parameter (0x300x3F), Zwischenzeichen (0x200x2F), Final
j := 2
for j < len(r) && r[j] >= 0x30 && r[j] <= 0x3f {
j++
}
for j < len(r) && r[j] >= 0x20 && r[j] <= 0x2f {
j++
}
if j >= len(r) {
return "", len(r), escNone // unvollständig
}
if r[j] == 'm' {
return string(r[:j+1]), j + 1, escSGR
}
return "", j + 1, escNone
case ']', 'P', 'X', '^', '_': // OSC/DCS/SOS/PM/APC: bis BEL oder ST
for j := 2; j < len(r); j++ {
if r[j] == 0x07 {
return "", j + 1, escNone
}
if r[j] == '\x1b' && j+1 < len(r) && r[j+1] == '\\' {
return "", j + 2, escNone
}
}
return "", len(r), escNone
case '(', ')', '*', '+', '#', '%': // Charset-/Zeichensatz-Wechsel
if len(r) >= 3 {
return "", 3, escNone
}
return "", len(r), escNone
default:
return "", 2, escNone
}
}
// Sanitize bereinigt Script-Output für die Anzeige in der TUI: behalten
// werden SGR-Farbsequenzen und Zeilenumbrüche; Cursor-Steuerung, OSC,
// Charset-Switches und andere Steuerzeichen werden entfernt. Jede Zeile
// wird auf width Spellen begrenzt und bei Farbnutzung mit Reset
// abgeschlossen, damit die Pane-Umrandung nicht verfärbt wird.
func Sanitize(s string, width int) string {
if width <= 0 {
width = 80
}
var b strings.Builder
b.Grow(len(s))
lineW, hasSGR := 0, false
runes := []rune(s)
for i := 0; i < len(runes); i++ {
r := runes[i]
switch {
case r == '\x1b':
seq, n, kind := parseEscape(runes[i:])
i += n - 1
if kind == escSGR {
b.WriteString(seq)
hasSGR = true
}
case r == '\n':
if hasSGR && !endsWithReset(&b) {
b.WriteString("\x1b[0m")
}
b.WriteByte('\n')
lineW, hasSGR = 0, false
case r == '\r', r == '\a', r == '\b', r == '\v', r == '\f':
// ignorieren
case r == '\t':
for k := 0; k < 4 && lineW < width; k++ {
b.WriteByte(' ')
lineW++
}
case r < 0x20 || r == 0x7f:
// übrige Steuerzeichen entfernen
default:
if lineW >= width {
continue // Zeile abgeschnitten
}
w := runewidth.RuneWidth(r)
if lineW+w > width {
lineW = width // breites Zeichen passt nicht mehr → Schnittstelle
continue
}
b.WriteRune(r)
lineW += w
}
}
out := b.String()
if hasSGR && !strings.HasSuffix(out, "\x1b[0m") {
out += "\x1b[0m" // letzte Zeile ohne Newline: offene Farbe schließen
}
return strings.TrimRight(out, "\n")
}
// endsWithReset prüft, ob der Builder bereits auf Farbe zurückgesetzt ist.
func endsWithReset(b *strings.Builder) bool {
return strings.HasSuffix(b.String(), "\x1b[0m")
}

View file

@ -0,0 +1,34 @@
package motd
import "testing"
func TestSanitizeKeepsSGRStripsRest(t *testing.T) {
in := "\x1b[1;32mgrün\x1b[0m\x1b[2J\x1b[?25lnormal\r\nnext\x1b]0;title\x07"
want := "\x1b[1;32mgrün\x1b[0mnormal\x1b[0m\nnext"
if got := Sanitize(in, 40); got != want {
t.Errorf("Sanitize() = %q, want %q", got, want)
}
}
func TestSanitizeTruncatesToWidth(t *testing.T) {
if got := Sanitize("abcdefghij", 4); got != "abcd" {
t.Errorf("plain: got %q, want %q", got, "abcd")
}
got := Sanitize("\x1b[31mabcdefghij\x1b[0m", 4)
want := "\x1b[31mabcd\x1b[0m"
if got != want {
t.Errorf("colored: got %q, want %q", got, want)
}
}
func TestSanitizeExpandsTabsAndStripsCharset(t *testing.T) {
if got := Sanitize("\x1b(Ba\tb", 20); got != "a b" {
t.Errorf("got %q, want %q", got, "a b")
}
}
func TestSanitizeDropsOtherControlChars(t *testing.T) {
if got := Sanitize("a\x00\x07\x08\x0bb", 20); got != "ab" {
t.Errorf("got %q, want %q", got, "ab")
}
}

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

@ -0,0 +1,74 @@
// Package motd enthält die Domain-Logik: Discovery der update-motd.d-Scripte,
// Diff/Plan von Soll-Zuständen, das Ausführen des Plans und die Preview.
package motd
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"motd-assist/internal/assets"
)
// Script ist ein Eintrag in /etc/update-motd.d.
type Script struct {
Name string // Dateiname inkl. Nummern-Präfix, z.B. "00-header"
Path string // Absoluter Pfad
Enabled bool // Executable-Bit gesetzt (run-parts führt nur diese aus)
Bundled bool // von motd-assist installiert (Inhalt entspricht einer Vorlage)
}
// MotdDir liefert den Pfad des update-motd.d-Verzeichnisses unter root.
func MotdDir(root string) string {
return filepath.Join(root, "etc", "update-motd.d")
}
// run-parts im pam_motd-Kontext ignoriert Dateien mit Punkten; wir halten
// uns an dieselbe Namenskonvention ([A-Za-z0-9_-]).
var validName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
// Discover listet alle Scripte im update-motd.d-Verzeichnis in
// run-parts-Reihenfolge (lexikalische Sortierung). Gebündelte Scripte
// werden per Inhaltsvergleich erkannt.
func Discover(root string) ([]Script, error) {
dir := MotdDir(root)
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("update-motd.d nicht lesbar: %w", err)
}
scripts := make([]Script, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !validName.MatchString(e.Name()) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
path := filepath.Join(dir, e.Name())
s := Script{
Name: e.Name(),
Path: path,
Enabled: info.Mode()&0o111 != 0,
}
if b, err := os.ReadFile(path); err == nil {
_, s.Bundled = assets.MatchScript(string(b))
}
scripts = append(scripts, s)
}
sort.Slice(scripts, func(i, j int) bool { return scripts[i].Name < scripts[j].Name })
return scripts, nil
}
// EnabledCount zählt die aktiven Scripte.
func EnabledCount(scripts []Script) int {
n := 0
for _, s := range scripts {
if s.Enabled {
n++
}
}
return n
}

207
internal/motd/state.go Normal file
View file

@ -0,0 +1,207 @@
package motd
import (
"fmt"
"regexp"
"slices"
"sort"
"strconv"
"strings"
)
// DesiredEntry beschreibt den Soll-Zustand eines Scripts.
// Remove markiert ein vorhandenes (gebündeltes) Script zur Deinstallation.
type DesiredEntry struct {
Name string
Enabled bool
Install string
Remove bool
}
// Plan-Ops: Es werden nur Modi, Namen und gebündelte Installationen
// geändert — niemals Inhalte fremder Scripte.
type (
ChmodOp struct {
Name string `json:"name"`
Enable bool `json:"enable"`
}
RenameOp struct {
From string `json:"from"`
To string `json:"to"`
}
InstallOp struct {
Name string `json:"name"`
Source string `json:"source"`
Enabled bool `json:"enabled"`
}
RemoveOp struct {
Name string `json:"name"`
}
)
// Plan ist der validier- und ausführbare Unterschied zwischen Ist und Soll.
type Plan struct {
Chmod []ChmodOp `json:"chmod,omitempty"`
Renames []RenameOp `json:"renames,omitempty"`
Installs []InstallOp `json:"installs,omitempty"`
Removes []RemoveOp `json:"removes,omitempty"`
}
// Empty meldet, ob der Plan nichts tut.
func (p Plan) Empty() bool { return p.NumOps() == 0 }
// NumOps zählt alle Operationen.
func (p Plan) NumOps() int {
return len(p.Chmod) + len(p.Renames) + len(p.Installs) + len(p.Removes)
}
// Summary liefert eine Kurzbeschreibung für Statusmeldungen.
func (p Plan) Summary() string {
var parts []string
if n := len(p.Removes); n > 0 {
parts = append(parts, fmt.Sprintf("%d entfernt", n))
}
if n := len(p.Installs); n > 0 {
parts = append(parts, fmt.Sprintf("%d installiert", n))
}
if n := len(p.Renames); n > 0 {
parts = append(parts, fmt.Sprintf("%d umbenannt", n))
}
if n := len(p.Chmod); n > 0 {
parts = append(parts, fmt.Sprintf("%d an/ausgeschaltet", n))
}
if len(parts) == 0 {
return "keine Änderungen"
}
return strings.Join(parts, ", ")
}
var prefixRe = regexp.MustCompile(`^([0-9]+)-`)
// suffixOf entfernt den führenden Nummern-Präfix ("00-header" → "header").
func suffixOf(name string) string {
if loc := prefixRe.FindStringIndex(name); loc != nil {
return name[loc[1]:]
}
return name
}
func prefixNum(name string) (float64, bool) {
m := prefixRe.FindStringSubmatch(name)
if m == nil {
return 0, false
}
n, err := strconv.ParseFloat(m[1], 64)
if err != nil {
return 0, false
}
return n, true
}
// targetName baut den kanonischen Namen für Position i (Schrittweite 10).
func targetName(i int, name string) string {
return fmt.Sprintf("%02d-%s", i*10, suffixOf(name))
}
// DesiredFromPreset baut die vollständige Soll-Liste aus einem Preset:
// Gelistete Scripte kommen in Preset-Reihenfolge, ungelistete System-Scripte
// behalten Zustand und Position (interleaved über ihren Nummern-Präfix).
// Ungelistete gebündelte Scripte werden zur Deinstallation markiert.
func DesiredFromPreset(current []Script, entries []DesiredEntry) []DesiredEntry {
curByName := make(map[string]Script, len(current))
for _, c := range current {
curByName[c.Name] = c
}
type slot struct {
key float64
entry DesiredEntry
}
var slots []slot
seen := make(map[string]bool)
for i, e := range entries {
if _, exists := curByName[e.Name]; e.Install == "" && !exists {
continue // Script existiert auf diesem System nicht
}
if seen[e.Name] || e.Remove {
continue
}
seen[e.Name] = true
slots = append(slots, slot{float64(i)*10 + 0.5, e})
}
for i, c := range current {
if seen[c.Name] {
continue
}
key := float64(i) * 10
if n, ok := prefixNum(c.Name); ok {
key = n
}
e := DesiredEntry{Name: c.Name, Enabled: c.Enabled}
if c.Bundled {
e.Remove = true // gebündelte Scripte verwaltet ausschließlich das Preset
}
slots = append(slots, slot{key, e})
}
sort.SliceStable(slots, func(i, j int) bool { return slots[i].key < slots[j].key })
out := make([]DesiredEntry, len(slots))
for i, s := range slots {
out[i] = s.entry
}
return out
}
// Diff berechnet den Plan von current nach desired.
func Diff(current []Script, desired []DesiredEntry) Plan {
var plan Plan
curByName := make(map[string]Script, len(current))
for _, c := range current {
curByName[c.Name] = c
}
var final []string // Reihenfolge der Dateien nach dem Apply
installIdx := make(map[string]int) // gewünschter Name → Index in plan.Installs
for _, e := range desired {
if e.Remove {
if _, exists := curByName[e.Name]; exists {
plan.Removes = append(plan.Removes, RemoveOp{Name: e.Name})
}
continue
}
final = append(final, e.Name)
cur, exists := curByName[e.Name]
if !exists {
if e.Install != "" {
installIdx[e.Name] = len(plan.Installs)
plan.Installs = append(plan.Installs, InstallOp{Name: e.Name, Source: e.Install, Enabled: e.Enabled})
}
continue
}
if cur.Enabled != e.Enabled {
plan.Chmod = append(plan.Chmod, ChmodOp{Name: e.Name, Enable: e.Enabled})
}
}
// Die Wunsch-Reihenfolge muss der lexikalischen Sortierung der Dateinamen
// entsprechen (run-parts-Reihenfolge). Tut sie das nicht, werden alle
// Positionen neu nummeriert vorhandene Dateien umbenannt und
// Installationen direkt unter dem Zielnamen angelegt.
if !slices.IsSorted(final) {
for i, name := range final {
target := targetName(i, name)
if cur, exists := curByName[name]; exists {
if target != cur.Name {
plan.Renames = append(plan.Renames, RenameOp{From: cur.Name, To: target})
}
} else if idx, ok := installIdx[name]; ok {
plan.Installs[idx].Name = target
}
}
}
return plan
}

120
internal/motd/state_test.go Normal file
View file

@ -0,0 +1,120 @@
package motd
import (
"reflect"
"testing"
)
func TestDiffToggleOnly(t *testing.T) {
current := []Script{{Name: "00-header", Enabled: true}, {Name: "10-sysinfo", Enabled: false}}
desired := []DesiredEntry{{Name: "00-header", Enabled: false}, {Name: "10-sysinfo", Enabled: false}}
plan := Diff(current, desired)
if len(plan.Chmod) != 1 || plan.Chmod[0].Name != "00-header" || plan.Chmod[0].Enable {
t.Errorf("Chmod = %+v, want 00-header disable", plan.Chmod)
}
if plan.Empty() {
t.Error("Plan sollte eine Operation haben, ist aber leer")
}
if len(plan.Renames) != 0 {
t.Errorf("Renames = %+v, want none", plan.Renames)
}
}
func TestDiffReorderRenumbers(t *testing.T) {
current := []Script{
{Name: "00-a", Enabled: true},
{Name: "10-b", Enabled: true},
{Name: "20-c", Enabled: true},
}
desired := []DesiredEntry{
{Name: "10-b", Enabled: true},
{Name: "00-a", Enabled: true},
{Name: "20-c", Enabled: true},
}
plan := Diff(current, desired)
want := []RenameOp{{From: "10-b", To: "00-b"}, {From: "00-a", To: "10-a"}}
if !reflect.DeepEqual(plan.Renames, want) {
t.Errorf("Renames = %+v, want %+v", plan.Renames, want)
}
}
func TestDiffKeepsPrefixesWhenOrderUnchanged(t *testing.T) {
current := []Script{{Name: "05-a", Enabled: true}, {Name: "07-b", Enabled: true}}
desired := []DesiredEntry{{Name: "05-a", Enabled: true}, {Name: "07-b", Enabled: true}}
if plan := Diff(current, desired); !plan.Empty() {
t.Errorf("Plan = %+v, want leer", plan)
}
}
func TestDiffInstallAndRemove(t *testing.T) {
current := []Script{
{Name: "00-header", Enabled: true},
{Name: "95-footer", Enabled: true, Bundled: true},
}
desired := []DesiredEntry{
{Name: "00-header", Enabled: true},
{Name: "10-brand-header", Enabled: true, Install: "brand-header"},
{Name: "95-footer", Remove: true},
}
plan := Diff(current, desired)
if len(plan.Installs) != 1 || plan.Installs[0].Source != "brand-header" {
t.Errorf("Installs = %+v", plan.Installs)
}
if len(plan.Removes) != 1 || plan.Removes[0].Name != "95-footer" {
t.Errorf("Removes = %+v", plan.Removes)
}
}
func TestDesiredFromPresetInterleavesUnlisted(t *testing.T) {
current := []Script{
{Name: "00-header", Enabled: true},
{Name: "50-motd-news", Enabled: true},
{Name: "90-updates-available", Enabled: false},
{Name: "95-footer", Enabled: true, Bundled: true},
}
entries := []DesiredEntry{
{Name: "10-brand-header", Enabled: true, Install: "brand-header"},
{Name: "00-header", Enabled: false},
}
got := DesiredFromPreset(current, entries)
var names []string
var removes []string
for _, e := range got {
if e.Remove {
removes = append(removes, e.Name)
} else {
names = append(names, e.Name)
}
}
wantNames := []string{"10-brand-header", "00-header", "50-motd-news", "90-updates-available"}
if !reflect.DeepEqual(names, wantNames) {
t.Errorf("Reihenfolge = %v, want %v", names, wantNames)
}
if !reflect.DeepEqual(removes, []string{"95-footer"}) {
t.Errorf("Removes = %v, want [95-footer]", removes)
}
// Ungelistete behalten ihren Zustand.
for _, e := range got {
if e.Name == "50-motd-news" && !e.Enabled {
t.Error("50-motd-news sollte aktiviert bleiben")
}
if e.Name == "00-header" && e.Enabled {
t.Error("00-header sollte laut Preset deaktiviert werden")
}
}
}
func TestDesiredFromPresetSkipsMissing(t *testing.T) {
current := []Script{{Name: "00-header", Enabled: true}}
entries := []DesiredEntry{{Name: "10-gibt-es-nicht", Enabled: true}}
got := DesiredFromPreset(current, entries)
if len(got) != 1 || got[0].Name != "00-header" {
t.Errorf("got %+v", got)
}
}