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:
2026-09-25 21:33:46 +05:30
parent 1723ab53b8
commit faf5b27328
49 changed files with 6795 additions and 747 deletions

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
}

233
legacy/go/auth/pam.go Normal file
View File

@@ -0,0 +1,233 @@
// 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>
#include <stdint.h>
// Match the //export-generated signature.
extern int latchd_pam_conv(int, struct pam_message **,
struct pam_response **, void *);
// Store a uintptr_t handle in C heap so the Go GC never sees a Go pointer.
static void *storeHandle(uintptr_t h) {
uintptr_t *p = malloc(sizeof(uintptr_t));
if (p) *p = h;
return p;
}
static uintptr_t loadHandle(void *p) {
return *(uintptr_t *)p;
}
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"
"os"
ouser "os/user"
"runtime"
"runtime/cgo"
"strings"
"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
pamData unsafe.Pointer
pamCB cgo.Handle
}
//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(C.loadHandle(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
msgSlice := unsafe.Slice(msg, int(numMsg))
respSlice := unsafe.Slice(r, int(numMsg))
for i := 0; i < int(numMsg); i++ {
switch msgSlice[i].msg_style {
case C.PAM_PROMPT_ECHO_OFF, C.PAM_PROMPT_ECHO_ON:
c := C.CString(pw)
if c == nil {
return C.PAM_BUF_ERR
}
respSlice[i].resp = c
respSlice[i].resp_retcode = 0
default:
respSlice[i].resp = nil
respSlice[i].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)
cData := C.storeHandle(C.uintptr_t(h))
conv := C.makePAMConv(cData)
var pamh *C.pam_handle_t
ret := C.pam_start(cSvc, cUser, &conv, &pamh)
if ret != C.PAM_SUCCESS {
h.Delete()
C.free(cData)
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)
h.Delete()
C.free(cData)
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)
h.Delete()
C.free(cData)
return nil, &AuthError{ErrAccountValidation, msg}
}
u, err := ouser.Lookup(user)
if err != nil {
C.pam_end(pamh, ret)
h.Delete()
C.free(cData)
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: shellFromPasswd(uid),
pamHandle: pamh,
pamData: cData,
pamCB: h,
}, 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
}
if c.pamData != nil {
C.free(c.pamData)
c.pamData = nil
}
c.pamCB.Delete()
}
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
}
func shellFromPasswd(uid uint32) string {
data, err := os.ReadFile("/etc/passwd")
if err != nil {
return "/bin/sh"
}
uidStr := fmt.Sprint(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"
}

77
legacy/go/auth/utmpx.go Normal file
View File

@@ -0,0 +1,77 @@
// auth — UTMPX user accounting (glibc only).
//
// Mirrors vendor/src/auth/utmpx.rs.
package auth
/*
#include <utmpx.h>
#include <string.h>
#include <stdlib.h>
#include <stdint.h>
#include "utmpx_time.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()
C.latchd_set_utmpx_time(&ut, C.int64_t(us/1_000_000), 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
}

View File

@@ -0,0 +1,10 @@
// Helper: assign seconds + microseconds to utmpx's ut_tv field.
// The C compiler handles the type conversion, so this works whether
// ut_tv is struct timeval (long tv_sec/tv_usec) or the compat struct
// (__uint32_t tv_sec / __int32_t tv_usec).
#include <stdint.h>
static inline void latchd_set_utmpx_time(struct utmpx *ut, int64_t sec, int32_t usec) {
ut->ut_tv.tv_sec = sec;
ut->ut_tv.tv_usec = usec;
}