(Feat): Initial Commit

This commit is contained in:
2026-07-18 18:16:06 +05:30
commit f6085c342b
22 changed files with 3124 additions and 0 deletions

81
src/auth/faillock.go Normal file
View File

@@ -0,0 +1,81 @@
// auth — faillock integration.
//
// Reads /etc/security/faillock.conf for deny count, executes
// faillock --user to get tally, supports --reset via admin bypass.
package auth
import (
"bytes"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
)
type FaillockStatus struct {
Enabled bool
Failures int
MaxTries int
Remaining int
LockedOut bool
}
func GetFaillockStatus(user string) (FaillockStatus, error) {
s := FaillockStatus{}
data, err := os.ReadFile("/etc/security/faillock.conf")
if err != nil {
return s, nil
}
s.Enabled = true
s.MaxTries = 3
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "deny") {
parts := strings.Split(line, "=")
if len(parts) == 2 {
if n, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil {
s.MaxTries = n
}
}
}
}
var out bytes.Buffer
cmd := exec.Command("faillock", "--user", user)
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return s, fmt.Errorf("faillock: %w", err)
}
s.Failures = countFaillockLines(out.String())
s.Remaining = s.MaxTries - s.Failures
if s.Remaining < 0 {
s.Remaining = 0
}
s.LockedOut = s.Remaining <= 0
return s, nil
}
func ResetFaillock(user string) error {
return exec.Command("faillock", "--user", user, "--reset").Run()
}
func countFaillockLines(out string) int {
n := 0
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "When") ||
strings.HasPrefix(line, "Source") || strings.HasPrefix(line, "Date") {
continue
}
if strings.Contains(line, ":") {
continue
}
n++
}
return n
}

176
src/auth/pam.go Normal file
View File

@@ -0,0 +1,176 @@
// auth — PAM authentication with CGO bindings.
//
// Uses runtime.LockOSThread to prevent goroutine migration during
// PAM transactions, as PAM modules rely on thread-local storage.
//
// Mirrors vendor/src/auth/pam.rs and vendor/src/auth/mod.rs.
package auth
/*
#cgo LDFLAGS: -lpam
#include <security/pam_appl.h>
#include <stdlib.h>
// Match the //export-generated signature.
extern int latchd_pam_conv(int, struct pam_message **,
struct pam_response **, void *);
// Helper to create the conv struct with the proper cast.
static struct pam_conv makePAMConv(void *data) {
struct pam_conv c;
c.conv = (int (*)(int, const struct pam_message **,
struct pam_response **, void *))latchd_pam_conv;
c.appdata_ptr = data;
return c;
}
*/
import "C"
import (
"fmt"
ouser "os/user"
"runtime"
"runtime/cgo"
"unsafe"
)
type AuthError struct {
Kind int
Msg string
}
const (
ErrPAMService = iota
ErrAccountValidation
ErrHomeDirInvalid
ErrShellInvalid
ErrUsernameNotFound
ErrSessionOpen
)
func (e *AuthError) Error() string { return e.Msg }
type Credentials struct {
Username string
UID uint32
PrimaryGID uint32
AllGIDs []uint32
HomeDir string
Shell string
pamHandle *C.pam_handle_t
}
//export latchd_pam_conv
func latchd_pam_conv(numMsg C.int, msg **C.struct_pam_message, resp **C.struct_pam_response, data unsafe.Pointer) C.int {
h := *(*cgo.Handle)(data)
pw := h.Value().(string)
r := (*C.struct_pam_response)(C.calloc(C.ulong(numMsg), C.sizeof_struct_pam_response))
if r == nil {
return C.PAM_BUF_ERR
}
*resp = r
s := unsafe.Slice((**C.struct_pam_response)(unsafe.Pointer(&r)), int(numMsg))
for i := 0; i < int(numMsg); i++ {
c := C.CString(pw)
if c == nil {
return C.PAM_BUF_ERR
}
s[i] = &C.struct_pam_response{resp: c, resp_retcode: 0}
}
return C.PAM_SUCCESS
}
func Validate(user, pass, service string) (*Credentials, error) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
cSvc := C.CString(service)
cUser := C.CString(user)
defer C.free(unsafe.Pointer(cSvc))
defer C.free(unsafe.Pointer(cUser))
h := cgo.NewHandle(pass)
defer h.Delete()
conv := C.makePAMConv(unsafe.Pointer(&h))
var pamh *C.pam_handle_t
ret := C.pam_start(cSvc, cUser, &conv, &pamh)
if ret != C.PAM_SUCCESS {
return nil, &AuthError{ErrPAMService, fmt.Sprintf("pam_start: %s", pamErr(pamh, ret))}
}
ret = C.pam_authenticate(pamh, 0)
if ret != C.PAM_SUCCESS {
msg := fmt.Sprintf("auth failed: %s", pamErr(pamh, ret))
C.pam_end(pamh, ret)
return nil, &AuthError{ErrAccountValidation, msg}
}
ret = C.pam_acct_mgmt(pamh, 0)
if ret != C.PAM_SUCCESS {
msg := fmt.Sprintf("account: %s", pamErr(pamh, ret))
C.pam_end(pamh, ret)
return nil, &AuthError{ErrAccountValidation, msg}
}
u, err := ouser.Lookup(user)
if err != nil {
C.pam_end(pamh, ret)
return nil, &AuthError{ErrUsernameNotFound, fmt.Sprintf("lookup %s: %v", user, err)}
}
uid := atou32(u.Uid)
gid := atou32(u.Gid)
gids := []uint32{gid}
if ids, err := u.GroupIds(); err == nil {
for _, s := range ids {
g := atou32(s)
if g != gid {
gids = append(gids, g)
}
}
}
return &Credentials{
Username: user,
UID: uid,
PrimaryGID: gid,
AllGIDs: gids,
HomeDir: u.HomeDir,
Shell: "/bin/sh",
pamHandle: pamh,
}, nil
}
func (c *Credentials) OpenSession() error {
ret := C.pam_open_session(c.pamHandle, 0)
if ret != C.PAM_SUCCESS {
return &AuthError{ErrSessionOpen, fmt.Sprintf("session: %s", pamErr(c.pamHandle, ret))}
}
return nil
}
func (c *Credentials) CloseSession() {
if c.pamHandle != nil {
C.pam_close_session(c.pamHandle, 0)
C.pam_end(c.pamHandle, 0)
c.pamHandle = nil
}
}
func pamErr(h *C.pam_handle_t, e C.int) string {
return C.GoString(C.pam_strerror(h, e))
}
func atou32(s string) uint32 {
var n uint32
for _, c := range s {
if c < '0' || c > '9' {
break
}
n = n*10 + uint32(c-'0')
}
return n
}

76
src/auth/utmpx.go Normal file
View File

@@ -0,0 +1,76 @@
// auth — UTMPX user accounting (glibc only).
//
// Mirrors vendor/src/auth/utmpx.rs.
package auth
/*
#include <utmpx.h>
#include <string.h>
#include <sys/time.h>
#include <stdlib.h>
*/
import "C"
import (
"time"
"unsafe"
)
type UtmpxSession struct {
ok bool
username string
tty uint8
}
func AddUtmpx(user string, tty uint8, pid uint32) *UtmpxSession {
s := &UtmpxSession{username: user, tty: tty}
cUser := C.CString(user)
defer C.free(unsafe.Pointer(cUser))
var ut C.struct_utmpx
C.memset(unsafe.Pointer(&ut), 0, C.sizeof_struct_utmpx)
ut.ut_type = C.USER_PROCESS
ut.ut_pid = C.pid_t(pid)
n := len(user)
if n > 32 {
n = 32
}
for i := 0; i < n; i++ {
ut.ut_user[i] = C.char(user[i])
}
ttyCh := byte('0' + tty)
ut.ut_line[0] = 't'
ut.ut_line[1] = 't'
ut.ut_line[2] = 'y'
ut.ut_line[3] = C.char(ttyCh)
ut.ut_id[0] = C.char(ttyCh)
us := time.Now().UnixMicro()
ut.ut_tv.tv_sec = C.__uint32_t(us / 1_000_000)
ut.ut_tv.tv_usec = C.__int32_t(us % 1_000_000)
C.setutxent()
C.pututxline(&ut)
s.ok = true
return s
}
func (s *UtmpxSession) Close() {
if !s.ok {
return
}
var ut C.struct_utmpx
C.memset(unsafe.Pointer(&ut), 0, C.sizeof_struct_utmpx)
ut.ut_type = C.DEAD_PROCESS
C.setutxent()
C.pututxline(&ut)
C.endutxent()
s.ok = false
}

309
src/config/config.go Normal file
View File

@@ -0,0 +1,309 @@
package config
import (
"fmt"
"os"
"strings"
"github.com/BurntSushi/toml"
)
type FocusBehaviour string
const (
FocusFirstNonCached FocusBehaviour = "default"
FocusNone FocusBehaviour = "no-focus"
FocusEnvironment FocusBehaviour = "environment"
FocusUsername FocusBehaviour = "username"
FocusPassword FocusBehaviour = "password"
)
type ShellLoginFlag string
const (
ShellLoginNone ShellLoginFlag = "none"
ShellLoginShort ShellLoginFlag = "short"
ShellLoginLong ShellLoginFlag = "long"
)
type BackgroundStyle struct {
Color string `toml:"color"`
ShowBorder bool `toml:"show_border"`
BorderColor string `toml:"border_color"`
}
type BackgroundConfig struct {
ShowBackground bool `toml:"show_background"`
Style BackgroundStyle `toml:"style"`
}
type PowerControl struct {
Hint string `toml:"hint"`
HintColor string `toml:"hint_color"`
HintModifiers string `toml:"hint_modifiers"`
Key string `toml:"key"`
Cmd string `toml:"cmd"`
}
type PowerControlConfig struct {
HintMargin uint16 `toml:"hint_margin"`
BaseEntries []PowerControl `toml:"base_entries"`
Entries []PowerControl `toml:"entries"`
}
type SwitcherConfig struct {
SwitcherVisibility string `toml:"switcher_visibility"`
ToggleHint string `toml:"toggle_hint"`
ToggleHintColor string `toml:"toggle_hint_color"`
ToggleHintModifiers string `toml:"toggle_hint_modifiers"`
IncludeTTYShell bool `toml:"include_tty_shell"`
Remember bool `toml:"remember"`
ShowMovers bool `toml:"show_movers"`
MoverColor string `toml:"mover_color"`
MoverColorFocused string `toml:"mover_color_focused"`
MoverModifiers string `toml:"mover_modifiers"`
MoverModifiersFocused string `toml:"mover_modifiers_focused"`
LeftMover string `toml:"left_mover"`
RightMover string `toml:"right_mover"`
MoverMargin uint16 `toml:"mover_margin"`
ShowNeighbours bool `toml:"show_neighbours"`
NeighbourColor string `toml:"neighbour_color"`
NeighbourColorFocused string `toml:"neighbour_color_focused"`
NeighbourModifiers string `toml:"neighbour_modifiers"`
NeighbourModifiersFocused string `toml:"neighbour_modifiers_focused"`
NeighbourMargin uint16 `toml:"neighbour_margin"`
SelectedColor string `toml:"selected_color"`
SelectedColorFocused string `toml:"selected_color_focused"`
SelectedModifiers string `toml:"selected_modifiers"`
SelectedModifiersFocused string `toml:"selected_modifiers_focused"`
MaxDisplayLength uint16 `toml:"max_display_length"`
NoEnvsText string `toml:"no_envs_text"`
NoEnvsColor string `toml:"no_envs_color"`
NoEnvsColorFocused string `toml:"no_envs_color_focused"`
NoEnvsModifiers string `toml:"no_envs_modifiers"`
NoEnvsModifiersFocused string `toml:"no_envs_modifiers_focused"`
}
type InputFieldStyle struct {
ShowTitle bool `toml:"show_title"`
Title string `toml:"title"`
ShowBorder bool `toml:"show_border"`
TitleColor string `toml:"title_color"`
TitleColorFocused string `toml:"title_color_focused"`
ContentColor string `toml:"content_color"`
ContentColorFocused string `toml:"content_color_focused"`
BorderColor string `toml:"border_color"`
BorderColorFocused string `toml:"border_color_focused"`
UseMaxWidth bool `toml:"use_max_width"`
MaxWidth uint16 `toml:"max_width"`
}
type UsernameFieldConfig struct {
Remember bool `toml:"remember"`
Style InputFieldStyle `toml:"style"`
}
type PasswordFieldConfig struct {
ReplacementChar string `toml:"content_replacement_character"`
Style InputFieldStyle `toml:"style"`
}
type X11Config struct {
Display string `toml:"x11_display"`
ServerTimeoutSecs uint16 `toml:"xserver_timeout_secs"`
ServerLogPath string `toml:"xserver_log_path"`
ServerPath string `toml:"xserver_path"`
XauthPath string `toml:"xauth_path"`
ScriptsPath string `toml:"scripts_path"`
SetupPath string `toml:"xsetup_path"`
SessionsPath string `toml:"xsessions_path"`
}
type WaylandConfig struct {
ScriptsPath string `toml:"scripts_path"`
SessionsPath string `toml:"wayland_sessions_path"`
}
type Config struct {
TTY uint8 `toml:"tty"`
MainLogPath string `toml:"main_log_path"`
ClientLogPath string `toml:"client_log_path"`
CachePath string `toml:"cache_path"`
DoLog bool `toml:"do_log"`
PAMService string `toml:"pam_service"`
SystemShell string `toml:"system_shell"`
InitialPath string `toml:"initial_path"`
ShellLoginFlag ShellLoginFlag `toml:"shell_login_flag"`
FocusBehaviour FocusBehaviour `toml:"focus_behaviour"`
Background BackgroundConfig `toml:"background"`
PowerControls PowerControlConfig `toml:"power_controls"`
Switcher SwitcherConfig `toml:"environment_switcher"`
Username UsernameFieldConfig `toml:"username_field"`
Password PasswordFieldConfig `toml:"password_field"`
X11 X11Config `toml:"x11"`
Wayland WaylandConfig `toml:"wayland"`
}
func Default() Config {
var c Config
if err := toml.Unmarshal([]byte(defaultTOML), &c); err != nil {
panic("default config: " + err.Error())
}
return c
}
func LoadPartial(filename string, vars map[string]string, dst *Config) error {
raw, err := os.ReadFile(filename)
if err != nil {
return fmt.Errorf("read %s: %w", filename, err)
}
if vars != nil {
raw = []byte(resolveVars(string(raw), vars))
}
return toml.Unmarshal(raw, dst)
}
func LoadVariables(filename string) (map[string]string, error) {
raw, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("read %s: %w", filename, err)
}
// Parse a flat TOML table of string values.
vars := make(map[string]string)
var table map[string]any
if err := toml.Unmarshal(raw, &table); err != nil {
return nil, fmt.Errorf("parse %s: %w", filename, err)
}
for k, v := range table {
vars[k] = fmt.Sprint(v)
}
return vars, nil
}
// ── variable substitution ───────────────────────────────────────────────
func resolveVars(in string, vars map[string]string) string {
for k, v := range vars {
in = strings.ReplaceAll(in, "$"+k, v)
}
return in
}
// ── embedded default config ─────────────────────────────────────────────
const defaultTOML = `
tty = 2
main_log_path = "/var/log/latchd.log"
client_log_path = "/var/log/latchd.client.log"
cache_path = "/var/cache/latchd"
do_log = true
pam_service = "latchd"
system_shell = "/bin/sh"
initial_path = "/usr/local/sbin:/usr/local/bin:/usr/bin"
shell_login_flag = "short"
focus_behaviour = "default"
[background]
show_background = true
[background.style]
color = "black"
show_border = true
border_color = "white"
[power_controls]
hint_margin = 2
entries = []
[[power_controls.base_entries]]
hint = "Shutdown"
hint_color = "dark gray"
hint_modifiers = ""
key = "F1"
cmd = "systemctl poweroff -l"
[[power_controls.base_entries]]
hint = "Reboot"
hint_color = "dark gray"
hint_modifiers = ""
key = "F2"
cmd = "systemctl reboot -l"
[environment_switcher]
switcher_visibility = "visible"
toggle_hint = "Switcher %key%"
toggle_hint_color = "dark gray"
toggle_hint_modifiers = ""
include_tty_shell = false
remember = true
show_movers = true
mover_color = "dark gray"
mover_modifiers = ""
mover_color_focused = "orange"
mover_modifiers_focused = "bold"
left_mover = "<"
right_mover = ">"
mover_margin = 1
show_neighbours = true
neighbour_color = "dark gray"
neighbour_modifiers = ""
neighbour_color_focused = "gray"
neighbour_modifiers_focused = ""
neighbour_margin = 1
selected_color = "gray"
selected_modifiers = "underlined"
selected_color_focused = "white"
selected_modifiers_focused = "bold"
max_display_length = 8
no_envs_text = "No environments..."
no_envs_color = "white"
no_envs_modifiers = ""
no_envs_color_focused = "red"
no_envs_modifiers_focused = ""
[username_field]
remember = true
[username_field.style]
show_title = true
title = "Login"
title_color = "white"
content_color = "white"
title_color_focused = "orange"
content_color_focused = "orange"
show_border = true
border_color = "white"
border_color_focused = "orange"
use_max_width = true
max_width = 48
[password_field]
content_replacement_character = "*"
[password_field.style]
show_title = true
title = "Password"
title_color = "white"
content_color = "white"
title_color_focused = "orange"
content_color_focused = "orange"
show_border = true
border_color = "white"
border_color_focused = "orange"
use_max_width = true
max_width = 48
[x11]
x11_display = ":1"
xserver_timeout_secs = 60
xserver_log_path = "/var/log/latchd.xorg.log"
xserver_path = "/usr/bin/X"
xauth_path = "/usr/bin/xauth"
scripts_path = "/etc/latchd/wms"
xsetup_path = "/etc/latchd/xsetup.sh"
xsessions_path = "/usr/share/xsessions"
[wayland]
scripts_path = "/etc/latchd/wayland"
wayland_sessions_path = "/usr/share/wayland-sessions"
`

28
src/go.mod Normal file
View File

@@ -0,0 +1,28 @@
module latchd
go 1.22.0
require (
github.com/BurntSushi/toml v1.4.0
github.com/charmbracelet/bubbles v0.18.0
github.com/charmbracelet/bubbletea v0.26.6
github.com/charmbracelet/lipgloss v0.11.0
github.com/mattn/go-runewidth v0.0.15
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/x/ansi v0.1.2 // indirect
github.com/charmbracelet/x/input v0.1.0 // indirect
github.com/charmbracelet/x/term v0.1.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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.21.0 // indirect
)

40
src/go.sum Normal file
View File

@@ -0,0 +1,40 @@
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
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.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
github.com/charmbracelet/bubbletea v0.26.6 h1:zTCWSuST+3yZYZnVSvbXwKOPRSNZceVeqpzOLN2zq1s=
github.com/charmbracelet/bubbletea v0.26.6/go.mod h1:dz8CWPlfCCGLFbBlTY4N7bjLiyOGDJEnd2Muu7pOWhk=
github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g=
github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8=
github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY=
github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ=
github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28=
github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI=
github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw=
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-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/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.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
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/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.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.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

785
src/login/login.go Normal file
View File

@@ -0,0 +1,785 @@
package login
import (
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"time"
"unsafe"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"latchd/auth"
"latchd/config"
"latchd/session"
"latchd/tui/bg"
)
// ── pixelated user avatar ───────────────────────────────────────────────
var avatarArt = []string{
` ▄▄▄▄▄ `,
` ▄███████▄ `,
` ██ ██ ██ `,
` ██ ██ `,
` ██ ███ ██ `,
` █████████ `,
` ██ ██ ██ `,
` ██ ██ `,
}
// ── messages ─────────────────────────────────────────────────────────────
type tickMsg time.Time
type authResult struct {
ok bool
err error
faillock auth.FaillockStatus
}
type adminBypassResult struct {
ok bool
err error
}
type mode int
const (
modeNormal mode = iota
modeSwitcher
modeUsername
modePassword
modeAdminUser
modeAdminPass
)
// ── result (sent back to main on successful login) ──────────────────────
type Result struct {
Username string
Password string
Env session.PostLoginEnv
Config config.Config
Preview bool
}
// ── model ───────────────────────────────────────────────────────────────
type Model struct {
cfg config.Config
preview bool
width int
height int
automata *bg.Automata
envs []session.PostLoginEnv
selIdx int
username string
password string
ucur int
pcur int
uscroll int
pscroll int
adminUser string
adminPass string
aucur int
apcur int
lockedFor string
mode mode
status string
statusErr bool
shakeOff int
shakeLeft float64
shakeDec float64
spring float64
quitting bool
result *Result
}
func New(cfg config.Config, preview bool) *Model {
raw := session.ScanEnvs(
cfg.X11.SessionsPath, cfg.Wayland.SessionsPath,
cfg.X11.ScriptsPath, cfg.Wayland.ScriptsPath,
cfg.Switcher.IncludeTTYShell,
)
m := &Model{cfg: cfg, preview: preview, envs: raw}
switch cfg.FocusBehaviour {
case config.FocusEnvironment:
if len(raw) > 0 {
m.mode = modeSwitcher
} else {
m.mode = modeUsername
}
case config.FocusUsername:
m.mode = modeUsername
case config.FocusPassword:
m.mode = modePassword
default:
if len(raw) > 0 {
m.mode = modeSwitcher
} else {
m.mode = modeUsername
}
}
return m
}
func (m *Model) Init() tea.Cmd {
return tea.Batch(tick(), tea.EnterAltScreen)
}
// ── update ──────────────────────────────────────────────────────────────
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
if m.automata != nil {
m.automata.Resize(msg.Width, msg.Height)
}
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
case tickMsg:
if m.automata != nil {
m.automata.Tick()
}
m.tickShake()
m.tickSpring()
return m, tick()
case authResult:
return m.handleAuth(msg)
case adminBypassResult:
return m.handleBypass(msg)
}
return m, nil
}
func (m *Model) tickShake() {
if m.shakeLeft > 0.01 || m.shakeLeft < -0.01 {
m.shakeDec += 0.05
m.shakeLeft *= 0.82
m.shakeOff = int(m.shakeLeft * 6.0 * (1.0 - m.shakeDec))
if m.shakeDec >= 1.0 {
m.shakeOff, m.shakeLeft, m.shakeDec = 0, 0, 0
}
}
}
func (m *Model) tickSpring() {
if m.spring > 0.5 || m.spring < -0.5 {
m.spring *= 0.75
} else {
m.spring = 0
}
}
func (m *Model) handleAuth(msg authResult) (tea.Model, tea.Cmd) {
if msg.ok {
m.result = &Result{
Username: m.username, Password: m.password,
Env: m.selectedEnv(), Config: m.cfg, Preview: m.preview,
}
return m, tea.Quit
}
m.status = "incorrect username or password"
m.statusErr = true
m.shakeLeft = 1.0
m.shakeDec = 0
m.password = ""
m.pcur = 0
m.pscroll = 0
if msg.faillock.Enabled && msg.faillock.LockedOut {
m.lockedFor = m.username
m.status = fmt.Sprintf("account locked — %d/%d attempts [Ctrl+U to unlock]",
msg.faillock.Failures, msg.faillock.MaxTries)
}
return m, nil
}
func (m *Model) handleBypass(msg adminBypassResult) (tea.Model, tea.Cmd) {
if msg.ok {
m.lockedFor = ""
m.mode = modeNormal
m.adminUser = ""
m.adminPass = ""
m.status = "account unlocked — try logging in again"
m.statusErr = false
} else {
m.status = "override failed: " + msg.err.Error()
m.statusErr = true
}
return m, nil
}
// ── keys ────────────────────────────────────────────────────────────────
func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
k := msg.String()
if m.mode == modeAdminUser || m.mode == modeAdminPass {
return m.adminKey(msg)
}
switch k {
case "esc":
if m.preview {
m.quitting = true
return m, tea.Quit
}
m.mode, m.status = modeNormal, ""
return m, nil
case "ctrl+c":
if m.preview {
m.quitting = true
return m, tea.Quit
}
return m, nil
case "ctrl+u":
if m.lockedFor != "" {
m.mode = modeAdminUser
m.status = "admin unlock · enter username"
m.statusErr = false
return m, nil
}
}
switch k {
case "tab", "down", "ctrl+n":
m.mode = m.nextMode()
case "shift+tab", "up", "ctrl+p":
m.mode = m.prevMode()
}
if k == "enter" && m.mode == modePassword {
return m, m.loginCmd()
}
if len(k) >= 2 && k[0] == 'f' {
m.handleFn(k)
return m, nil
}
switch m.mode {
case modeSwitcher:
switch k {
case "left", "h":
if m.selIdx > 0 {
m.selIdx--
m.spring = -4
}
case "right", "l":
if m.selIdx < len(m.envs)-1 {
m.selIdx++
m.spring = 4
}
}
case modeUsername:
handleField(k, &m.username, &m.ucur, &m.uscroll)
case modePassword:
handleField(k, &m.password, &m.pcur, &m.pscroll)
}
return m, nil
}
func (m *Model) adminKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
k := msg.String()
switch k {
case "esc":
m.mode, m.adminUser, m.adminPass, m.status = modeNormal, "", "", ""
return m, nil
case "enter":
if m.mode == modeAdminUser && m.adminUser != "" {
m.mode = modeAdminPass
m.status = "admin unlock · enter password"
return m, nil
}
if m.mode == modeAdminPass && m.adminPass != "" {
return m, m.adminBypassCmd()
}
case "tab":
if m.mode == modeAdminUser {
m.mode = modeAdminPass
} else {
m.mode = modeAdminUser
}
default:
if m.mode == modeAdminUser {
handleField(k, &m.adminUser, &m.aucur, &m.aucur)
} else {
handleField(k, &m.adminPass, &m.apcur, &m.apcur)
}
}
return m, nil
}
func (m *Model) nextMode() mode {
switch m.mode {
case modeNormal:
if len(m.envs) > 0 {
return modeSwitcher
}
return modeUsername
case modeSwitcher:
return modeUsername
case modeUsername:
return modePassword
}
return m.mode
}
func (m *Model) prevMode() mode {
switch m.mode {
case modeSwitcher:
return modeNormal
case modeUsername:
if len(m.envs) > 0 {
return modeSwitcher
}
return modeNormal
case modePassword:
return modeUsername
}
return modeNormal
}
func (m *Model) handleFn(k string) {
for _, pc := range m.cfg.PowerControls.BaseEntries {
if strings.EqualFold(k, pc.Key) {
exec.Command(m.cfg.SystemShell, "-c", pc.Cmd).Start()
}
}
for _, pc := range m.cfg.PowerControls.Entries {
if strings.EqualFold(k, pc.Key) {
exec.Command(m.cfg.SystemShell, "-c", pc.Cmd).Start()
}
}
}
func (m *Model) selectedEnv() session.PostLoginEnv {
if len(m.envs) == 0 || m.selIdx >= len(m.envs) {
return session.PostLoginEnv{}
}
return m.envs[m.selIdx]
}
// ── commands ────────────────────────────────────────────────────────────
func (m *Model) loginCmd() tea.Cmd {
if m.preview {
return func() tea.Msg { time.Sleep(800 * time.Millisecond); return authResult{ok: true} }
}
user, pass, svc := m.username, m.password, m.cfg.PAMService
return func() tea.Msg {
_, err := auth.Validate(user, pass, svc)
if err == nil {
return authResult{ok: true}
}
fs, _ := auth.GetFaillockStatus(user)
return authResult{ok: false, err: err, faillock: fs}
}
}
func (m *Model) adminBypassCmd() tea.Cmd {
au, ap, tgt := m.adminUser, m.adminPass, m.lockedFor
return func() tea.Msg {
if _, err := auth.Validate(au, ap, "su"); err != nil {
return adminBypassResult{ok: false, err: fmt.Errorf("admin auth failed")}
}
if err := auth.ResetFaillock(tgt); err != nil {
return adminBypassResult{ok: false, err: fmt.Errorf("faillock: %w", err)}
}
return adminBypassResult{ok: true}
}
}
// ── queries ────────────────────────────────────────────────────────────
func (m *Model) Result() *Result { return m.result }
func (m *Model) Quitting() bool { return m.quitting }
func (m *Model) SetAutomata(a *bg.Automata) { m.automata = a }
// ── view ───────────────────────────────────────────────────────────────
var (
accent = lipgloss.Color("208") // orange
subtle = lipgloss.Color("8") // grey
bright = lipgloss.Color("15") // white
errCol = lipgloss.Color("1") // red
infoCol = lipgloss.Color("3") // yellow
greenCol = lipgloss.Color("2") // green
avatarStyle = lipgloss.NewStyle().Foreground(accent)
fieldBox = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(subtle).
Padding(0, 1).
Width(34)
fieldBoxFocused = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(accent).
Padding(0, 1).
Width(34)
fieldLabel = lipgloss.NewStyle().Foreground(subtle).Padding(0, 0)
fieldText = lipgloss.NewStyle().Foreground(bright)
fieldFocus = lipgloss.NewStyle().Foreground(accent)
swSelected = lipgloss.NewStyle().Foreground(bright).Bold(true)
swNeighbour = lipgloss.NewStyle().Foreground(subtle)
swMover = lipgloss.NewStyle().Foreground(accent).Bold(true)
swDot = lipgloss.NewStyle().Foreground(subtle)
swDotActive = lipgloss.NewStyle().Foreground(accent)
powerHint = lipgloss.NewStyle().Foreground(subtle).Faint(true)
capsOnStyle = lipgloss.NewStyle().Foreground(greenCol).Bold(true)
capsOffStyle = lipgloss.NewStyle().Foreground(subtle).Faint(true)
statusErrStyle = lipgloss.NewStyle().Foreground(errCol)
statusInfoStyle = lipgloss.NewStyle().Foreground(infoCol)
adminLabel = lipgloss.NewStyle().Foreground(infoCol).Bold(true)
)
func (m *Model) View() string {
if m.quitting || m.result != nil {
return ""
}
content := m.renderLayout()
if m.automata != nil && m.width > 0 && m.height > 0 {
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
content,
lipgloss.WithWhitespaceChars(" "),
lipgloss.WithWhitespaceForeground(lipgloss.Color("0")),
)
}
return content
}
func (m *Model) renderLayout() string {
// top bar: power hints
top := m.renderPowerHints()
top = lipgloss.NewStyle().Width(80).Align(lipgloss.Left).Render(top)
// avatar + form side by side
avatar := m.renderAvatar()
form := m.renderForm()
mid := lipgloss.JoinHorizontal(lipgloss.Top, avatar, " ", form)
// DE switcher below
sw := m.renderSwitcherDots()
// caps lock
caps := m.renderCapsLock()
// admin unlock
var admin string
if m.mode == modeAdminUser || m.mode == modeAdminPass {
admin = m.renderAdminForm()
}
// status
st := m.renderStatus()
var parts []string
parts = append(parts, top)
parts = append(parts, "")
parts = append(parts, mid)
parts = append(parts, "")
parts = append(parts, sw)
if caps != "" {
parts = append(parts, caps)
}
if admin != "" {
parts = append(parts, "")
parts = append(parts, admin)
}
if st != "" {
parts = append(parts, st)
}
joined := lipgloss.JoinVertical(lipgloss.Center, parts...)
if m.shakeOff != 0 {
pad := strings.Repeat(" ", abs(m.shakeOff))
if m.shakeOff > 0 {
joined = pad + joined
}
}
return joined
}
func (m *Model) renderPowerHints() string {
var hints []string
for _, pc := range m.cfg.PowerControls.BaseEntries {
hints = append(hints, powerHint.Render(pc.Key+" "+pc.Hint))
}
for _, pc := range m.cfg.PowerControls.Entries {
hints = append(hints, powerHint.Render(pc.Key+" "+pc.Hint))
}
return strings.Join(hints, " ")
}
func (m *Model) renderAvatar() string {
var lines []string
for _, row := range avatarArt {
lines = append(lines, avatarStyle.Render(row))
}
return lipgloss.JoinVertical(lipgloss.Left, lines...)
}
func (m *Model) renderForm() string {
userLabel := fieldLabel.Render("username")
if m.mode == modeUsername {
userLabel = fieldFocus.Render("▸ username")
}
userVal := m.username
if userVal == "" {
userVal = " "
}
ubox := fieldBoxFocused
utxt := fieldFocus
if m.mode != modeUsername {
ubox = fieldBox
utxt = fieldText
}
userField := ubox.Render(userLabel + "\n" + utxt.Render(userVal))
passLabel := fieldLabel.Render("password")
if m.mode == modePassword {
passLabel = fieldFocus.Render("▸ password")
}
passVal := strings.Repeat("•", len(m.password))
if passVal == "" {
passVal = " "
}
pbox := fieldBoxFocused
ptxt := fieldFocus
if m.mode != modePassword {
pbox = fieldBox
ptxt = fieldText
}
passField := pbox.Render(passLabel + "\n" + ptxt.Render(passVal))
return lipgloss.JoinVertical(lipgloss.Left, userField, "", passField)
}
func (m *Model) renderSwitcherDots() string {
if len(m.envs) == 0 {
return swNeighbour.Align(lipgloss.Center).Render("no desktop environments found")
}
cfg := m.cfg.Switcher
w := int(cfg.MaxDisplayLength)
var parts []string
if cfg.ShowMovers && m.selIdx > 0 {
parts = append(parts, swMover.Render(cfg.LeftMover+" "))
}
if cfg.ShowNeighbours && m.selIdx > 0 {
parts = append(parts, swNeighbour.Render(padTrim(m.envs[m.selIdx-1].Title, w)+" "))
}
cur := swSelected.Render(padTrim(m.envs[m.selIdx].Title, w))
parts = append(parts, cur)
if cfg.ShowNeighbours && m.selIdx < len(m.envs)-1 {
parts = append(parts, swNeighbour.Render(" "+padTrim(m.envs[m.selIdx+1].Title, w)))
}
if cfg.ShowMovers && m.selIdx < len(m.envs)-1 {
parts = append(parts, swMover.Render(" "+cfg.RightMover))
}
swLine := lipgloss.JoinHorizontal(lipgloss.Center, parts...)
// Dot indicators
var dots []string
for i := 0; i < len(m.envs); i++ {
if i == m.selIdx {
dots = append(dots, swDotActive.Render("●"))
} else {
dots = append(dots, swDot.Render("·"))
}
}
dotLine := strings.Join(dots, " ")
return lipgloss.JoinVertical(lipgloss.Center, swLine, dotLine)
}
func (m *Model) renderCapsLock() string {
if capsLockOn() {
return capsOnStyle.Align(lipgloss.Center).Render("⬤ Caps Lock")
}
return ""
}
func (m *Model) renderAdminForm() string {
label := adminLabel.Render("── admin override ──")
auLabel := fieldLabel.Render("admin username")
if m.mode == modeAdminUser {
auLabel = fieldFocus.Render("▸ admin username")
}
auVal := m.adminUser
if auVal == "" {
auVal = " "
}
auBox := fieldBox
if m.mode == modeAdminUser {
auBox = fieldBoxFocused
}
auField := auBox.Render(auLabel + "\n" + fieldText.Render(auVal))
apLabel := fieldLabel.Render("admin password")
if m.mode == modeAdminPass {
apLabel = fieldFocus.Render("▸ admin password")
}
apVal := strings.Repeat("•", len(m.adminPass))
if apVal == "" {
apVal = " "
}
apBox := fieldBox
if m.mode == modeAdminPass {
apBox = fieldBoxFocused
}
apField := apBox.Render(apLabel + "\n" + fieldText.Render(apVal))
return lipgloss.JoinVertical(lipgloss.Center,
label, "", auField, "", apField,
)
}
func (m *Model) renderStatus() string {
if m.status == "" {
return ""
}
st := statusInfoStyle
if m.statusErr {
st = statusErrStyle
}
return st.Align(lipgloss.Center).Width(70).Render(m.status)
}
// ── caps lock detection ────────────────────────────────────────────────
const kdgkbled = 0x4B64
func capsLockOn() bool {
fd := int(os.Stdin.Fd())
var leds int32
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL,
uintptr(fd), kdgkbled, uintptr(unsafe.Pointer(&leds)))
if errno != 0 {
return false
}
return leds&0x04 != 0
}
// ── field editing ──────────────────────────────────────────────────────
func handleField(k string, buf *string, cur, scroll *int) {
switch {
case k == "backspace" || k == "ctrl+h":
if *cur == 0 && *scroll == 0 {
return
}
moveLeft(cur, scroll, *buf)
r := []rune(*buf)
idx := *scroll + *cur
if idx < len(r) {
*buf = string(append(r[:idx], r[idx+1:]...))
}
case k == "delete" || k == "ctrl+d":
r := []rune(*buf)
idx := *scroll + *cur
if idx < len(r) {
*buf = string(append(r[:idx], r[idx+1:]...))
}
case k == "ctrl+a":
*cur, *scroll = 0, 0
case k == "ctrl+e":
*cur, *scroll = len([]rune(*buf)), 0
case k == "ctrl+l", k == "ctrl+u":
*buf, *cur, *scroll = "", 0, 0
case k == "left" || k == "ctrl+b":
moveLeft(cur, scroll, *buf)
case k == "right" || k == "ctrl+f":
moveRight(cur, scroll, *buf)
default:
if len(k) == 1 {
r := []rune(*buf)
idx := *scroll + *cur
if idx > len(r) {
idx = len(r)
}
ch := []rune(k)[0]
r = append(r[:idx], append([]rune{ch}, r[idx:]...)...)
*buf = string(r)
if *cur >= 30 {
*scroll++
} else {
*cur++
}
}
}
}
func moveLeft(cur, scroll *int, buf string) {
if *cur > 0 {
*cur--
return
}
if *scroll > 0 {
*scroll--
}
}
func moveRight(cur, scroll *int, buf string) {
if *scroll+*cur >= len([]rune(buf)) {
return
}
if *cur >= 30 {
*scroll++
} else {
*cur++
}
}
func padTrim(s string, w int) string {
if len(s) > w {
return s[:w]
}
return s
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
// ── tick ────────────────────────────────────────────────────────────────
func tick() tea.Cmd {
return tea.Tick(time.Second/30, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}

260
src/main.go Normal file
View File

@@ -0,0 +1,260 @@
// latchd — TUI display/login manager for GNU/Linux and BSD.
//
// Supports TTY, X11, and Wayland sessions with PAM authentication.
// Go rewrite of Lemurs (Rust source under vendor/).
package main
import (
"flag"
"fmt"
"io"
"log"
"os"
"os/signal"
"syscall"
"unsafe"
tea "github.com/charmbracelet/bubbletea"
"latchd/auth"
"latchd/config"
"latchd/login"
"latchd/tui/bg"
)
var (
Version = "0.1.0"
Commit = "unknown"
BuildTime = "unknown"
)
const defaultConfig = "/etc/latchd/config.toml"
const defaultVars = "/etc/latchd/variables.toml"
func main() {
var (
configPath string
varsPath string
ttyNum int
preview bool
noLog bool
showConf bool
printVer bool
xsessions string
wlsessions string
initPath string
)
flag.StringVar(&configPath, "config", defaultConfig, "")
flag.StringVar(&configPath, "c", defaultConfig, "")
flag.StringVar(&varsPath, "variables", defaultVars, "")
flag.StringVar(&varsPath, "v", defaultVars, "")
flag.IntVar(&ttyNum, "tty", 0, "")
flag.BoolVar(&preview, "preview", false, "")
flag.BoolVar(&noLog, "no-log", false, "")
flag.BoolVar(&showConf, "show-config", false, "")
flag.BoolVar(&printVer, "version", false, "")
flag.BoolVar(&printVer, "V", false, "")
flag.StringVar(&xsessions, "xsessions", "", "")
flag.StringVar(&wlsessions, "wlsessions", "", "")
flag.StringVar(&initPath, "initial-path", "", "")
flag.Parse()
if printVer {
fmt.Printf("latchd %s commit %s built %s\n", Version, Commit, BuildTime)
os.Exit(0)
}
cfg := config.Default()
if initPath != "" {
cfg.InitialPath = initPath
}
if xsessions != "" {
cfg.X11.SessionsPath = xsessions
}
if wlsessions != "" {
cfg.Wayland.SessionsPath = wlsessions
}
vars, _ := config.LoadVariables(varsPath)
if err := config.LoadPartial(configPath, vars, &cfg); err != nil {
if configPath != defaultConfig {
fmt.Fprintf(os.Stderr, "config: %v\n", err)
os.Exit(1)
}
}
if showConf {
fmt.Printf("tty = %d\npam_service = %q\nshell = %q\nfocus = %q\n",
cfg.TTY, cfg.PAMService, cfg.SystemShell, cfg.FocusBehaviour)
os.Exit(0)
}
if !noLog && !preview {
setupLog(cfg.MainLogPath)
}
if !preview {
if _, ok := os.LookupEnv("XDG_SESSION_TYPE"); ok {
fmt.Fprintln(os.Stderr, "latchd: already in a session — use --preview")
os.Exit(1)
}
if os.Getuid() != 0 {
fmt.Fprintf(os.Stderr, "latchd: must be root (uid %d)\n", os.Getuid())
os.Exit(1)
}
if ttyNum != 0 {
cfg.TTY = uint8(ttyNum)
}
if err := chvt(int(cfg.TTY)); err != nil {
log.Printf("chvt %d: %v", cfg.TTY, err)
}
}
for {
res := launchUI(cfg, preview)
if res == nil {
break
}
if err := spawnSession(res); err != nil {
log.Printf("session: %v", err)
}
if !preview {
chvt(int(cfg.TTY))
}
}
}
func launchUI(cfg config.Config, preview bool) *login.Result {
m := login.New(cfg, preview)
if cfg.Background.ShowBackground {
m.SetAutomata(bg.NewAutomata(80, 24))
}
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
final, err := p.Run()
if err != nil {
log.Printf("tui: %v", err)
os.Exit(1)
}
lm, ok := final.(*login.Model)
if !ok {
return nil
}
return lm.Result()
}
func spawnSession(res *login.Result) error {
if res.Preview {
log.Println("preview — skipping real session")
return nil
}
log.Printf("spawning %s → %s (%s)", res.Username, res.Env.Title, res.Env.Kind)
creds, err := auth.Validate(res.Username, res.Password, res.Config.PAMService)
if err != nil {
return fmt.Errorf("auth: %w", err)
}
defer creds.CloseSession()
pid, err := syscall.ForkExec("/proc/self/exe", []string{
"latchd-session",
"--user", creds.Username,
"--uid", fmt.Sprintf("%d", creds.UID),
"--gid", fmt.Sprintf("%d", creds.PrimaryGID),
"--home", creds.HomeDir,
"--shell", creds.Shell,
"--env-kind", res.Env.Kind,
"--env-exec", res.Env.XinitrcPath,
"--tty", fmt.Sprintf("%d", res.Config.TTY),
}, &syscall.ProcAttr{
Env: os.Environ(),
Dir: creds.HomeDir,
Files: []uintptr{0, 1, 2},
Sys: &syscall.SysProcAttr{Setsid: true},
})
if err != nil {
return fmt.Errorf("fork: %w", err)
}
creds.OpenSession()
var ws syscall.WaitStatus
if _, err := syscall.Wait4(pid, &ws, 0, nil); err != nil {
return fmt.Errorf("wait: %w", err)
}
log.Printf("session pid %d exited %d", pid, ws.ExitStatus())
return nil
}
func setupLog(path string) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
fmt.Fprintf(os.Stderr, "log %s: %v\n", path, err)
os.Exit(1)
}
log.SetOutput(io.MultiWriter(f, os.Stderr))
log.SetFlags(log.LstdFlags)
}
// ── chvt ────────────────────────────────────────────────────────────────
const vtA = 0x5606
const vtW = 0x5607
const kbt = 0x4B33
const kb1 = 0x02
const kb2 = 0x01
func isCon(fd uintptr) bool {
var a int32
_, _, e := syscall.Syscall(syscall.SYS_IOCTL, fd, kbt, uintptr(unsafe.Pointer(&a)))
return e == 0 && (a == kb1 || a == kb2)
}
func openCon(fn string) (int, error) {
for _, fl := range []int{os.O_RDWR, os.O_RDONLY, os.O_WRONLY} {
fd, err := syscall.Open(fn, fl, 0)
if err != nil {
if err == syscall.EACCES {
continue
}
break
}
if isCon(uintptr(fd)) {
return fd, nil
}
syscall.Close(fd)
}
return 0, fmt.Errorf("openCon: %s", fn)
}
func getCon() (int, error) {
for _, p := range []string{"/dev/tty", "/dev/tty0", "/dev/vc/0", "/dev/console"} {
if fd, err := openCon(p); err == nil {
return fd, nil
}
}
for fd := 0; fd < 3; fd++ {
if isCon(uintptr(fd)) {
return fd, nil
}
}
return 0, fmt.Errorf("no console")
}
func chvt(n int) error {
fd, err := getCon()
if err != nil {
return err
}
defer syscall.Close(fd)
if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), vtA, uintptr(n)); e != 0 {
return fmt.Errorf("VT_ACTIVATE")
}
if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), vtW, uintptr(n)); e != 0 {
return fmt.Errorf("VT_WAITACTIVE")
}
return nil
}
func init() {
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-ch
os.Exit(0)
}()
}

64
src/session/env.go Normal file
View File

@@ -0,0 +1,64 @@
// session — environment variable setup for latchd sessions.
//
// Mirrors vendor/src/post_login/env_variables.rs.
package session
import (
"fmt"
"os"
"strconv"
)
func SetBasicVariables(user, home, shell, path string) {
os.Chdir(home)
os.Setenv("HOME", home)
os.Setenv("SHELL", shell)
os.Setenv("USER", user)
os.Setenv("LOGNAME", user)
os.Setenv("PATH", path)
}
func SetDisplay(display string) {
os.Setenv("DISPLAY", display)
}
func RemoveXDG() {
for _, v := range []string{
"XDG_SESSION_CLASS", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP",
"XDG_SEAT", "XDG_VTNR", "XDG_RUNTIME_DIR", "XDG_SESSION_ID",
"XDG_CONFIG_DIR", "XDG_CACHE_HOME", "XDG_DATA_HOME",
"XDG_STATE_HOME", "XDG_DATA_DIRS", "XDG_CONFIG_DIRS",
} {
os.Unsetenv(v)
}
}
func SetSessionParams(sessionType string) {
os.Setenv("XDG_SESSION_CLASS", "user")
os.Setenv("XDG_SESSION_TYPE", sessionType)
}
func SetSeatVars(tty uint8) {
setOrOwn("XDG_SEAT", "seat0")
setOrOwn("XDG_VTNR", strconv.Itoa(int(tty)))
}
func SetSessionVars(uid uint32) {
setOrOwn("XDG_RUNTIME_DIR", fmt.Sprintf("/run/user/%d", uid))
setOrOwn("XDG_SESSION_ID", "1")
}
func SetXDGCommonPaths(home string) {
setOrOwn("XDG_CONFIG_HOME", home+"/.config")
setOrOwn("XDG_CACHE_HOME", home+"/.cache")
setOrOwn("XDG_DATA_HOME", home+"/.local/share")
setOrOwn("XDG_STATE_HOME", home+"/.local/state")
setOrOwn("XDG_DATA_DIRS", "/usr/local/share:/usr/share")
setOrOwn("XDG_CONFIG_DIRS", "/etc/xdg")
}
func setOrOwn(k, v string) {
if _, ok := os.LookupEnv(k); !ok {
os.Setenv(k, v)
}
}

198
src/session/exec.go Normal file
View File

@@ -0,0 +1,198 @@
// session — process execution and session spawning.
//
// Mirrors vendor/src/post_login/mod.rs and wait_with_log.rs.
package session
import (
"fmt"
"io"
"os"
"os/exec"
"strconv"
"strings"
"syscall"
"latchd/config"
)
type PostLoginEnv struct {
Kind string // "x11", "wayland", "tty"
Title string
XinitrcPath string
ScriptPath string
}
type SpawnedEnv struct {
Kind string
Server *exec.Cmd
Client *exec.Cmd
logFd *os.File
}
const logLimit = 67_108_864
type limitWriter struct {
w io.Writer
n int64
max int64
}
func (l *limitWriter) Write(p []byte) (int, error) {
if l.n >= l.max {
return len(p), nil
}
rem := l.max - l.n
if int64(len(p)) > rem {
p = p[:rem]
}
n, err := l.w.Write(p)
l.n += int64(n)
return n, err
}
func Spawn(env *PostLoginEnv, uid, gid uint32, groups []uint32, cfg *config.Config) (*SpawnedEnv, error) {
var loginFlag string
switch cfg.ShellLoginFlag {
case config.ShellLoginShort:
loginFlag = "-l"
case config.ShellLoginLong:
loginFlag = "--login"
}
switch env.Kind {
case "x11":
return spawnX11(env, uid, gid, groups, cfg, loginFlag)
case "wayland":
return spawnWayland(env, uid, gid, groups, cfg, loginFlag)
default:
return spawnTTY(env, uid, gid, groups, cfg)
}
}
func spawnX11(env *PostLoginEnv, uid, gid uint32, groups []uint32, cfg *config.Config, loginFlag string) (*SpawnedEnv, error) {
server, err := setupX(uid, gid, groups, cfg)
if err != nil {
return nil, fmt.Errorf("X setup: %w", err)
}
args := shellCmd(cfg.SystemShell, loginFlag, cfg.X11.SetupPath+" "+env.XinitrcPath)
client := privCmd(args, uid, gid, groups)
logF := openLog(cfg.ClientLogPath)
if logF != nil {
lw := &limitWriter{w: logF, max: logLimit}
client.Stdout = lw
client.Stderr = lw
}
if err := client.Start(); err != nil {
return nil, fmt.Errorf("X11 client: %w", err)
}
return &SpawnedEnv{Kind: "x11", Server: server, Client: client, logFd: logF}, nil
}
func spawnWayland(env *PostLoginEnv, uid, gid uint32, groups []uint32, cfg *config.Config, loginFlag string) (*SpawnedEnv, error) {
args := shellCmd(cfg.SystemShell, loginFlag, env.ScriptPath)
client := privCmd(args, uid, gid, groups)
logF := openLog(cfg.ClientLogPath)
if logF != nil {
lw := &limitWriter{w: logF, max: logLimit}
client.Stdout = lw
client.Stderr = lw
}
if err := client.Start(); err != nil {
return nil, fmt.Errorf("Wayland: %w", err)
}
return &SpawnedEnv{Kind: "wayland", Client: client, logFd: logF}, nil
}
func spawnTTY(env *PostLoginEnv, uid, gid uint32, groups []uint32, cfg *config.Config) (*SpawnedEnv, error) {
shell := userShell(int(uid))
_ = env
_ = cfg
client := privCmd([]string{shell}, uid, gid, groups)
client.Stdin = os.Stdin
client.Stdout = os.Stdout
client.Stderr = os.Stderr
if err := client.Start(); err != nil {
return nil, fmt.Errorf("TTY: %w", err)
}
return &SpawnedEnv{Kind: "tty", Client: client}, nil
}
func (s *SpawnedEnv) Wait() error {
defer func() {
if s.logFd != nil {
s.logFd.Close()
}
}()
if s.Kind == "x11" && s.Server != nil {
defer func() {
s.Server.Process.Signal(syscall.SIGTERM)
s.Server.Wait()
}()
}
if s.Client != nil {
return s.Client.Wait()
}
return nil
}
func (s *SpawnedEnv) PID() int {
if s.Client != nil && s.Client.Process != nil {
return s.Client.Process.Pid
}
return 0
}
func shellCmd(shell, loginFlag, command string) []string {
a := []string{shell}
if loginFlag != "" {
a = append(a, loginFlag)
}
return append(a, "-c", command)
}
func privCmd(args []string, uid, gid uint32, groups []uint32) *exec.Cmd {
cmd := exec.Command(args[0], args[1:]...)
cmd.SysProcAttr = &syscall.SysProcAttr{
Credential: &syscall.Credential{
Uid: uid,
Gid: gid,
Groups: groups,
},
Setsid: true,
Setctty: false,
}
return cmd
}
func openLog(path string) *os.File {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return nil
}
return f
}
func userShell(uid int) string {
data, err := os.ReadFile("/etc/passwd")
if err != nil {
return "/bin/sh"
}
uidStr := strconv.Itoa(uid)
for _, line := range strings.Split(string(data), "\n") {
parts := strings.Split(line, ":")
if len(parts) >= 7 && parts[2] == uidStr && parts[6] != "" {
sh := parts[6]
if sh != "/sbin/nologin" && sh != "/usr/sbin/nologin" && sh != "/bin/false" {
return sh
}
}
}
return "/bin/sh"
}

133
src/session/scan.go Normal file
View File

@@ -0,0 +1,133 @@
// session — environment scanning from xsessions, wayland-sessions,
// and custom script directories. Includes desktop entry parser.
//
// Mirrors vendor/src/post_login/mod.rs:get_envs and parse_desktop_entry.
package session
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)
type DesktopEntry struct {
Name string
Exec string
Hidden bool
NoDisplay bool
}
func parseDesktopEntry(path string) (*DesktopEntry, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()
e := &DesktopEntry{}
inSection := false
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || line[0] == '#' {
continue
}
if line[0] == '[' && line[len(line)-1] == ']' {
inSection = line == "[Desktop Entry]"
continue
}
if !inSection {
continue
}
eq := strings.IndexByte(line, '=')
if eq < 0 {
continue
}
k, v := line[:eq], line[eq+1:]
switch k {
case "Name":
e.Name = v
case "Exec":
e.Exec = v
case "Hidden":
e.Hidden = strings.EqualFold(v, "true")
case "NoDisplay":
e.NoDisplay = strings.EqualFold(v, "true")
}
}
if sc.Err() != nil {
return nil, sc.Err()
}
if e.Exec == "" {
return nil, fmt.Errorf("no Exec in %s", path)
}
if e.Name == "" {
e.Name = e.Exec
}
return e, nil
}
func ScanEnvs(xSessions, wlSessions, xScripts, wlScripts string, includeTTY bool) []PostLoginEnv {
var envs []PostLoginEnv
scanDesktopDir(xSessions, "x11", &envs)
scanDesktopDir(wlSessions, "wayland", &envs)
scanScriptDir(xScripts, "x11", &envs)
scanScriptDir(wlScripts, "wayland", &envs)
if len(envs) == 0 || includeTTY {
envs = append(envs, PostLoginEnv{Kind: "tty", Title: "TTYSHELL"})
}
return envs
}
func scanDesktopDir(dir, kind string, envs *[]PostLoginEnv) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".desktop" {
continue
}
path := filepath.Join(dir, e.Name())
de, err := parseDesktopEntry(path)
if err != nil || de.Hidden || de.NoDisplay {
continue
}
*envs = append(*envs, PostLoginEnv{
Kind: kind,
Title: de.Name,
XinitrcPath: de.Exec,
ScriptPath: de.Exec,
})
}
}
func scanScriptDir(dir, kind string, envs *[]PostLoginEnv) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if e.IsDir() {
continue
}
info, err := e.Info()
if err != nil {
continue
}
if info.Mode()&0111 == 0 {
continue
}
path := filepath.Join(dir, e.Name())
*envs = append(*envs, PostLoginEnv{
Kind: kind,
Title: e.Name(),
XinitrcPath: path,
ScriptPath: path,
})
}
}

4
src/session/wayland.go Normal file
View File

@@ -0,0 +1,4 @@
package session
// Wayland session spawning is handled in exec.go/spawnWayland.
// This file exists for future Wayland-specific compositor logic.

103
src/session/x11.go Normal file
View File

@@ -0,0 +1,103 @@
// session — X11 server setup.
//
// Mirrors vendor/src/post_login/x.rs:setup_x.
package session
import (
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"syscall"
"time"
"latchd/config"
)
func setupX(uid, gid uint32, groups []uint32, cfg *config.Config) (*exec.Cmd, error) {
display := os.Getenv("DISPLAY")
vtnr := os.Getenv("XDG_VTNR")
home := os.Getenv("HOME")
if display == "" || vtnr == "" || home == "" {
return nil, fmt.Errorf("missing DISPLAY/XDG_VTNR/HOME env vars")
}
xauthPath := filepath.Join(home, ".Xauthority")
os.Remove(xauthPath)
cookie, err := mCookie()
if err != nil {
return nil, err
}
xauthCmd := exec.Command(cfg.X11.XauthPath, "add", display, ".", cookie)
xauthCmd.SysProcAttr = &syscall.SysProcAttr{
Credential: &syscall.Credential{Uid: uid, Gid: gid, Groups: groups},
}
if out, err := xauthCmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("xauth: %w — %s", err, string(out))
}
os.Setenv("XAUTHORITY", xauthPath)
dd := vtnr
if len(dd) == 1 {
dd = "0" + dd
}
// Ignore SIGUSR1 so Xorg sends it when ready.
signal.Ignore(syscall.SIGUSR1)
cmd := exec.Command(cfg.SystemShell, "-c",
fmt.Sprintf("%s %s vt%s", cfg.X11.ServerPath, display, dd))
if logF := openLog(cfg.X11.ServerLogPath); logF != nil {
lw := &limitWriter{w: logF, max: logLimit}
cmd.Stdout = lw
cmd.Stderr = lw
defer logF.Close()
}
if err := cmd.Start(); err != nil {
signal.Reset(syscall.SIGUSR1)
return nil, fmt.Errorf("X server: %w", err)
}
signal.Reset(syscall.SIGUSR1)
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGUSR1)
defer signal.Stop(ch)
timeout := time.Duration(cfg.X11.ServerTimeoutSecs) * time.Second
deadline := time.After(timeout)
tick := time.NewTicker(100 * time.Millisecond)
defer tick.Stop()
for {
select {
case <-ch:
return cmd, nil
case <-deadline:
if cfg.X11.ServerTimeoutSecs == 0 {
continue
}
cmd.Process.Signal(syscall.SIGTERM)
cmd.Wait()
return nil, fmt.Errorf("X server timed out after %ds", cfg.X11.ServerTimeoutSecs)
case <-tick.C:
var ws syscall.WaitStatus
if pid, err := syscall.Wait4(cmd.Process.Pid, &ws, syscall.WNOHANG, nil); err == nil && pid == cmd.Process.Pid {
return nil, fmt.Errorf("X server exited with status %d", ws.ExitStatus())
}
}
}
}
func mCookie() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}

129
src/tui/bg/automata.go Normal file
View File

@@ -0,0 +1,129 @@
// bg — cellular automata background for the TUI.
//
// Conway's Game of Life rendered at 30 Hz using Unicode half-block
// characters (▀) to double the effective vertical resolution.
// The grid is double-buffered for zero-allocation ticking, and
// render output is built in a pre-allocated strings.Builder so the
package bg
import (
"math/rand"
"strings"
)
// Bright orange (accent) for alive cells, dark grey for dead.
const (
fgAlive = "\x1b[38;2;255;127;0m"
fgDead = "\x1b[38;2;30;30;30m"
bgAlive = "\x1b[48;2;255;127;0m"
bgDead = "\x1b[48;2;30;30;30m"
reset = "\x1b[0m"
block = "▀"
)
type Automata struct {
w, h int
a, b []uint8
useA bool
buf strings.Builder
}
func NewAutomata(w, h int) *Automata {
a := &Automata{useA: true}
a.Resize(w, h)
return a
}
func (a *Automata) Resize(w, h int) {
logH := h * 2
if a.w == w && a.h == logH {
return
}
a.w = w
a.h = logH
sz := w * logH
a.a = make([]uint8, sz)
a.b = make([]uint8, sz)
for i := 0; i < sz; i++ {
if rand.Float32() > 0.85 {
a.a[i] = 1
}
}
a.buf.Grow(w * h * 40)
}
func (a *Automata) Tick() {
src, dst := a.a, a.b
if !a.useA {
src, dst = a.b, a.a
}
w, h := a.w, a.h
for y := range h {
for x := range w {
i := y*w + x
alive := src[i]
n := a.count(src, x, y, w, h)
if alive == 1 && (n == 2 || n == 3) {
dst[i] = 1
} else if alive == 0 && n == 3 {
dst[i] = 1
} else {
dst[i] = 0
}
}
}
a.useA = !a.useA
}
func (a *Automata) count(g []uint8, x, y, w, h int) uint8 {
var c uint8
for dy := -1; dy <= 1; dy++ {
for dx := -1; dx <= 1; dx++ {
if dx == 0 && dy == 0 {
continue
}
nx, ny := x+dx, y+dy
if nx >= 0 && nx < w && ny >= 0 && ny < h {
c += g[ny*w+nx]
}
}
}
return c
}
// Frame returns the current generation as a single string suitable for
// writing to the alternate screen.
func (a *Automata) Frame() string {
a.buf.Reset()
src := a.a
if !a.useA {
src = a.b
}
w, h := a.w, a.h
for y := 0; y < h; y += 2 {
for x := range w {
top := src[y*w+x]
bot := uint8(0)
if y+1 < h {
bot = src[(y+1)*w+x]
}
if top == 1 {
a.buf.WriteString(fgAlive)
} else {
a.buf.WriteString(fgDead)
}
if bot == 1 {
a.buf.WriteString(bgAlive)
} else {
a.buf.WriteString(bgDead)
}
a.buf.WriteString(block)
}
a.buf.WriteString(reset)
if y < h-2 {
a.buf.WriteByte('\n')
}
}
return a.buf.String()
}