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:
81
legacy/go/auth/faillock.go
Normal file
81
legacy/go/auth/faillock.go
Normal 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
233
legacy/go/auth/pam.go
Normal 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
77
legacy/go/auth/utmpx.go
Normal 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
|
||||
}
|
||||
10
legacy/go/auth/utmpx_time.h
Normal file
10
legacy/go/auth/utmpx_time.h
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user