Certificate generation and sync, implemented proper grant and revocation flows. Pubkey uploading. Added openssh-client to Dockerfile
This commit is contained in:
@@ -15,18 +15,24 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
stateFileName = "accounts.json"
|
||||
maxUsernameLen = 32
|
||||
passwdFilePath = "/etc/passwd"
|
||||
sshDirName = ".ssh"
|
||||
authKeysName = "authorized_keys"
|
||||
stateFileName = "accounts.json"
|
||||
maxUsernameLen = 32
|
||||
passwdFilePath = "/etc/passwd"
|
||||
groupFilePath = "/etc/group"
|
||||
sshDirName = ".ssh"
|
||||
authKeysName = "authorized_keys"
|
||||
keywardenGroup = "keywarden"
|
||||
userCAPath = "/etc/ssh/keywarden_user_ca.pub"
|
||||
sshdConfigDropDir = "/etc/ssh/sshd_config.d"
|
||||
sshdConfigDropIn = "/etc/ssh/sshd_config.d/keywarden.conf"
|
||||
sshdConfigPath = "/etc/ssh/sshd_config"
|
||||
)
|
||||
|
||||
type AccessUser struct {
|
||||
UserID int
|
||||
Username string
|
||||
Email string
|
||||
Keys []string
|
||||
UserID int
|
||||
Username string
|
||||
Email string
|
||||
SystemUsername string
|
||||
}
|
||||
|
||||
type ReportAccount struct {
|
||||
@@ -65,14 +71,18 @@ func Sync(policy config.AccountPolicy, stateDir string, users []AccessUser) (Res
|
||||
}
|
||||
|
||||
desired := make(map[int]managedAccount, len(users))
|
||||
userIndex := make(map[int]AccessUser, len(users))
|
||||
for _, user := range users {
|
||||
systemUser := renderUsername(policy.UsernameTemplate, user.Username, user.UserID)
|
||||
systemUser := user.SystemUsername
|
||||
if strings.TrimSpace(systemUser) == "" {
|
||||
systemUser = renderUsername(policy.UsernameTemplate, user.Username, user.UserID)
|
||||
}
|
||||
desired[user.UserID] = managedAccount{UserID: user.UserID, SystemUser: systemUser}
|
||||
userIndex[user.UserID] = user
|
||||
}
|
||||
|
||||
var syncErr error
|
||||
if err := ensureGroup(keywardenGroup); err != nil && syncErr == nil {
|
||||
syncErr = err
|
||||
}
|
||||
for _, account := range current.Users {
|
||||
if _, ok := desired[account.UserID]; ok {
|
||||
continue
|
||||
@@ -84,8 +94,7 @@ func Sync(policy config.AccountPolicy, stateDir string, users []AccessUser) (Res
|
||||
}
|
||||
|
||||
for userID, account := range desired {
|
||||
accessUser := userIndex[userID]
|
||||
present, err := ensureAccount(account.SystemUser, policy, accessUser.Keys)
|
||||
present, err := ensureAccount(account.SystemUser, policy)
|
||||
if err != nil && syncErr == nil {
|
||||
syncErr = err
|
||||
}
|
||||
@@ -186,7 +195,7 @@ func userExists(username string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func ensureAccount(username string, policy config.AccountPolicy, keys []string) (bool, error) {
|
||||
func ensureAccount(username string, policy config.AccountPolicy) (bool, error) {
|
||||
exists, err := userExists(username)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -196,17 +205,20 @@ func ensureAccount(username string, policy config.AccountPolicy, keys []string)
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if err := lockPassword(username); err != nil {
|
||||
if err := ensureGroupMembership(username, keywardenGroup); err != nil {
|
||||
return true, err
|
||||
}
|
||||
if err := writeAuthorizedKeys(username, keys); err != nil {
|
||||
if err := enforceCertificateOnly(username, policy); err != nil {
|
||||
return true, err
|
||||
}
|
||||
if err := writeAuthorizedKeys(username, nil); err != nil {
|
||||
return true, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func createUser(username string, policy config.AccountPolicy) error {
|
||||
args := []string{"-U"}
|
||||
args := []string{"-U", "-G", keywardenGroup}
|
||||
if policy.CreateHome {
|
||||
args = append(args, "-m")
|
||||
} else {
|
||||
@@ -223,10 +235,20 @@ func createUser(username string, policy config.AccountPolicy) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func lockPassword(username string) error {
|
||||
func enforceCertificateOnly(username string, policy config.AccountPolicy) error {
|
||||
cmd := exec.Command("usermod", "-L", username)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("lock password %s: %w", username, err)
|
||||
return fmt.Errorf("lock account %s: %w", username, err)
|
||||
}
|
||||
if policy.DefaultShell != "" {
|
||||
shellCmd := exec.Command("usermod", "-s", policy.DefaultShell, username)
|
||||
if err := shellCmd.Run(); err != nil {
|
||||
return fmt.Errorf("set shell %s: %w", username, err)
|
||||
}
|
||||
}
|
||||
expiryCmd := exec.Command("chage", "-E", "-1", username)
|
||||
if err := expiryCmd.Run(); err != nil {
|
||||
return fmt.Errorf("clear expiry %s: %w", username, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -241,7 +263,7 @@ func revokeUser(username string, policy config.AccountPolicy) error {
|
||||
}
|
||||
var revokeErr error
|
||||
if policy.LockOnRevoke {
|
||||
if err := lockPassword(username); err != nil {
|
||||
if err := disableAccount(username); err != nil {
|
||||
revokeErr = err
|
||||
}
|
||||
}
|
||||
@@ -251,6 +273,157 @@ func revokeUser(username string, policy config.AccountPolicy) error {
|
||||
return revokeErr
|
||||
}
|
||||
|
||||
func disableAccount(username string) error {
|
||||
cmd := exec.Command("usermod", "-L", username)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("lock account %s: %w", username, err)
|
||||
}
|
||||
expiryCmd := exec.Command("chage", "-E", "0", username)
|
||||
if err := expiryCmd.Run(); err != nil {
|
||||
return fmt.Errorf("expire account %s: %w", username, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureGroup(name string) error {
|
||||
exists, err := groupExists(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
cmd := exec.Command("groupadd", name)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("groupadd %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func groupExists(name string) (bool, error) {
|
||||
file, err := os.Open(groupFilePath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("open group file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
fields := strings.SplitN(line, ":", 4)
|
||||
if len(fields) < 1 {
|
||||
continue
|
||||
}
|
||||
if fields[0] == name {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return false, fmt.Errorf("scan group file: %w", err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func ensureGroupMembership(username string, group string) error {
|
||||
cmd := exec.Command("usermod", "-a", "-G", group, username)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("usermod add %s to %s: %w", username, group, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnsureCA(publicKey string) error {
|
||||
key := strings.TrimSpace(publicKey)
|
||||
if key == "" {
|
||||
return errors.New("user CA public key required")
|
||||
}
|
||||
changed, err := writeCAKeyIfChanged(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configChanged, err := ensureSSHDConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed || configChanged {
|
||||
if err := reloadSSHD(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeCAKeyIfChanged(key string) (bool, error) {
|
||||
if data, err := os.ReadFile(userCAPath); err == nil {
|
||||
if strings.TrimSpace(string(data)) == key {
|
||||
return false, nil
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return false, fmt.Errorf("read user CA key: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(userCAPath, []byte(key+"\n"), 0o644); err != nil {
|
||||
return false, fmt.Errorf("write user CA key: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func ensureSSHDConfig() (bool, error) {
|
||||
content := fmt.Sprintf(
|
||||
"TrustedUserCAKeys %s\nMatch Group %s\n AuthorizedKeysFile none\n",
|
||||
userCAPath,
|
||||
keywardenGroup,
|
||||
)
|
||||
if info, err := os.Stat(sshdConfigDropDir); err == nil && info.IsDir() {
|
||||
if existing, err := os.ReadFile(sshdConfigDropIn); err == nil {
|
||||
if string(existing) == content {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(sshdConfigDropIn, []byte(content), 0o644); err != nil {
|
||||
return false, fmt.Errorf("write sshd drop-in: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
data, err := os.ReadFile(sshdConfigPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read sshd config: %w", err)
|
||||
}
|
||||
if strings.Contains(string(data), "TrustedUserCAKeys "+userCAPath) {
|
||||
return false, nil
|
||||
}
|
||||
updated := string(data)
|
||||
if !strings.HasSuffix(updated, "\n") {
|
||||
updated += "\n"
|
||||
}
|
||||
updated += "\n# Keywarden managed users\n" + content
|
||||
if err := os.WriteFile(sshdConfigPath, []byte(updated), 0o644); err != nil {
|
||||
return false, fmt.Errorf("write sshd config: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func reloadSSHD() error {
|
||||
if path, _ := exec.LookPath("systemctl"); path != "" {
|
||||
if err := exec.Command("systemctl", "reload", "sshd").Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := exec.Command("systemctl", "reload", "ssh").Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if path, _ := exec.LookPath("service"); path != "" {
|
||||
if err := exec.Command("service", "sshd", "reload").Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := exec.Command("service", "ssh", "reload").Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("unable to reload sshd")
|
||||
}
|
||||
|
||||
func writeAuthorizedKeys(username string, keys []string) error {
|
||||
entry, err := lookupUser(username)
|
||||
if err != nil {
|
||||
|
||||
@@ -88,10 +88,16 @@ type AccountKey struct {
|
||||
}
|
||||
|
||||
type AccountAccess struct {
|
||||
UserID int `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Keys []AccountKey `json:"keys"`
|
||||
UserID int `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
SystemUsername string `json:"system_username"`
|
||||
Keys []AccountKey `json:"keys"`
|
||||
}
|
||||
|
||||
type UserCAResponse struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
type AccountSyncEntry struct {
|
||||
@@ -145,24 +151,24 @@ func (c *Client) SyncAccounts(ctx context.Context, cfg *config.Config) error {
|
||||
if cfg == nil {
|
||||
return errors.New("config required for account sync")
|
||||
}
|
||||
ca, err := c.FetchUserCA(ctx, cfg.ServerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := accounts.EnsureCA(ca.PublicKey); err != nil {
|
||||
return err
|
||||
}
|
||||
users, err := c.FetchAccountAccess(ctx, cfg.ServerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
accessUsers := make([]accounts.AccessUser, 0, len(users))
|
||||
for _, user := range users {
|
||||
keys := make([]string, 0, len(user.Keys))
|
||||
for _, key := range user.Keys {
|
||||
if strings.TrimSpace(key.PublicKey) == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, strings.TrimSpace(key.PublicKey))
|
||||
}
|
||||
accessUsers = append(accessUsers, accounts.AccessUser{
|
||||
UserID: user.UserID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Keys: keys,
|
||||
UserID: user.UserID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
SystemUsername: user.SystemUsername,
|
||||
})
|
||||
}
|
||||
result, syncErr := accounts.Sync(cfg.AccountPolicy, cfg.StateDir, accessUsers)
|
||||
@@ -215,6 +221,34 @@ func (c *Client) FetchAccountAccess(ctx context.Context, serverID string) ([]Acc
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) FetchUserCA(ctx context.Context, serverID string) (*UserCAResponse, error) {
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
c.baseURL+"/agent/servers/"+serverID+"/ssh-ca",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build user ca request: %w", err)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch user ca: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, &HTTPStatusError{StatusCode: resp.StatusCode, Status: resp.Status}
|
||||
}
|
||||
var out UserCAResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("decode user ca: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(out.PublicKey) == "" {
|
||||
return nil, errors.New("user ca missing public key")
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *Client) SendSyncReport(ctx context.Context, serverID string, report SyncReportRequest) error {
|
||||
body, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user