feat: ship Rust login manager and GitLab release pipeline
Replace the legacy Go implementation as the production path, quarantine the reference tree, add deterministic release packaging and version metadata, and run verification through GitLab CI.
This commit is contained in:
73
legacy/go/session/env.go
Normal file
73
legacy/go/session/env.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// 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 SetDesktopVars(currentDesktop, sessionDesktop string) {
|
||||
if currentDesktop != "" {
|
||||
setOrOwn("XDG_CURRENT_DESKTOP", currentDesktop)
|
||||
}
|
||||
if sessionDesktop != "" {
|
||||
setOrOwn("XDG_SESSION_DESKTOP", sessionDesktop)
|
||||
}
|
||||
}
|
||||
|
||||
func setOrOwn(k, v string) {
|
||||
if _, ok := os.LookupEnv(k); !ok {
|
||||
os.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
200
legacy/go/session/exec.go
Normal file
200
legacy/go/session/exec.go
Normal file
@@ -0,0 +1,200 @@
|
||||
// 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
|
||||
DesktopNames string // for XDG_CURRENT_DESKTOP (colon-separated)
|
||||
SessionDesktop string // for XDG_SESSION_DESKTOP
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
149
legacy/go/session/scan.go
Normal file
149
legacy/go/session/scan.go
Normal file
@@ -0,0 +1,149 @@
|
||||
// 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
|
||||
DesktopNames 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 "DesktopNames":
|
||||
e.DesktopNames = 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
|
||||
}
|
||||
desktopNames, sessionDesktop := splitDesktopNames(de.DesktopNames, e.Name())
|
||||
*envs = append(*envs, PostLoginEnv{
|
||||
Kind: kind,
|
||||
Title: de.Name,
|
||||
XinitrcPath: de.Exec,
|
||||
ScriptPath: de.Exec,
|
||||
DesktopNames: desktopNames,
|
||||
SessionDesktop: sessionDesktop,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func splitDesktopNames(raw, fallback string) (currentDesktop, sessionDesktop string) {
|
||||
if raw == "" {
|
||||
return fallback, fallback
|
||||
}
|
||||
parts := strings.SplitN(raw, ";", 2)
|
||||
sessionDesktop = strings.TrimSpace(parts[0])
|
||||
currentDesktop = strings.ReplaceAll(raw, ";", ":")
|
||||
return
|
||||
}
|
||||
|
||||
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
legacy/go/session/wayland.go
Normal file
4
legacy/go/session/wayland.go
Normal 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
legacy/go/session/x11.go
Normal file
103
legacy/go/session/x11.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user