Loading batch/batch.go +164 −1 Original line number Diff line number Diff line Loading @@ -17,7 +17,11 @@ package batch import ( "bufio" "bytes" "crypto/sha256" _ "embed" "encoding/base64" "errors" "fmt" "os" Loading @@ -31,6 +35,7 @@ import ( "git.lyda.ie/pp/bulletin/storage" "git.lyda.ie/pp/bulletin/users" "github.com/adrg/xdg" "golang.org/x/crypto/ssh" ) //go:embed crontab Loading Loading @@ -110,7 +115,11 @@ the first user.`) if err != nil { system.ExpireLimit = 365 } err = q.SetSystem(ctx, system.Name, system.DefaultExpire, system.ExpireLimit) err = q.SetSystem(ctx, storage.SetSystemParams{ Name: system.Name, DefaultExpire: system.DefaultExpire, ExpireLimit: system.ExpireLimit, }) ask.CheckErr(err) login, err := ask.GetLine("Enter login of initial user: ") Loading Loading @@ -167,6 +176,57 @@ the first user.`) err = installCrontab(crontab.String()) ask.CheckErr(err) // Open registration setup. openReg, err := ask.GetLine("Allow unknown SSH keys to create accounts? [y/n]: ") if err == nil && strings.HasPrefix(strings.ToLower(strings.TrimSpace(openReg)), "y") { err = q.UpdateOpenRegistration(ctx, 1) ask.CheckErr(err) fmt.Println("Open registration enabled.") // Shibboleth setup. addShib, err := ask.GetLine("Would you like to add shibboleth challenge questions? [y/n]: ") if err == nil && strings.HasPrefix(strings.ToLower(strings.TrimSpace(addShib)), "y") { for { question, err := ask.GetLine("Enter question: ") if err != nil { break } answer, err := ask.GetLine("Enter answer: ") if err != nil { break } err = q.AddShibboleth(ctx, strings.TrimSpace(question), strings.TrimSpace(answer)) ask.CheckErr(err) fmt.Println("Shibboleth added.") more, err := ask.GetLine("Add another? [y/n]: ") if err != nil || !strings.HasPrefix(strings.ToLower(strings.TrimSpace(more)), "y") { break } } } // Print sshd config block. fmt.Printf(` --- Add the following to /etc/ssh/sshd_config --- Match User %s PasswordAuthentication no PubkeyAuthentication yes PermitTTY yes X11Forwarding no AllowTcpForwarding no PermitTunnel no ForceCommand %s --ssh AuthorizedKeysCommand %s authorized-keys %%u %%t %%k AuthorizedKeysCommandUser %s --- Then run: sudo systemctl reload sshd --- `, os.Getenv("USER"), bulletin, bulletin, os.Getenv("USER")) _, _ = ask.GetLine("Press Enter to continue after configuring sshd...") } // Mark that install has happened. err = touch(touchfile) ask.CheckErr(err) Loading Loading @@ -210,3 +270,106 @@ func NewUser(args []string) int { } return 0 } // MigrateKeys reads the ~/.ssh/authorized_keys file and migrates // bulletin entries into the ssh_keys database table. func MigrateKeys() int { store, err := storage.Open() ask.CheckErr(err) q := storage.New(store.DB) if err := MigrateKeysWithQueries(q); err != nil { fmt.Printf("ERROR: %s\n", err) return 1 } return 0 } // MigrateKeysWithQueries migrates authorized_keys entries into the // ssh_keys database table using the provided query handle. func MigrateKeysWithQueries(q *storage.Queries) error { ctx := storage.Context() bulletin, err := os.Executable() if err != nil { return fmt.Errorf("failed to find bulletin binary: %w", err) } keyfile := path.Join(xdg.Home, ".ssh", "authorized_keys") f, err := os.Open(keyfile) // #nosec G304 -- path is constructed from xdg.Home if err != nil { return fmt.Errorf("failed to open %s: %w", keyfile, err) } defer f.Close() migrated := 0 skipped := 0 scanner := bufio.NewScanner(f) for scanner.Scan() { line := bytes.TrimSpace(scanner.Bytes()) if len(line) == 0 { continue } pubKey, _, options, _, err := ssh.ParseAuthorizedKey(line) if err != nil { fmt.Printf("WARNING: skipping unparseable line: %s\n", err) skipped++ continue } // Extract login from the command= option. login := "" for _, opt := range options { parts := strings.SplitN(opt, "=", 2) if len(parts) != 2 || parts[0] != "command" { continue } cmd := strings.Split(strings.Trim(parts[1], "\" "), " ") if len(cmd) >= 3 && cmd[0] == bulletin && cmd[1] == "-u" { login = cmd[2] } break } if login == "" { skipped++ continue } // Compute fingerprint and key details. h := sha256.Sum256(pubKey.Marshal()) fp := "SHA256:" + base64.RawStdEncoding.EncodeToString(h[:]) keyBytes := ssh.MarshalAuthorizedKey(pubKey) keyLine := strings.TrimSpace(string(keyBytes)) keyParts := strings.SplitN(keyLine, " ", 3) keyType := keyParts[0] keyBase64 := "" if len(keyParts) >= 2 { keyBase64 = keyParts[1] } comment := "" if len(keyParts) >= 3 { comment = keyParts[2] } err = q.AddSSHKey(ctx, storage.AddSSHKeyParams{ Fingerprint: fp, Login: login, KeyType: keyType, Pubkey: keyBase64, Comment: comment, }) if err != nil { fmt.Printf("WARNING: skipping key for %s: %s\n", login, err) skipped++ continue } migrated++ } if err := scanner.Err(); err != nil { return fmt.Errorf("error reading authorized_keys: %w", err) } fmt.Printf("Migrated %d keys, skipped %d.\n", migrated, skipped) return nil } key/key.go +97 −1 Original line number Diff line number Diff line // Package key manages the authorized keys file. // Package key manages the authorized keys file and the ssh_keys database table. package key import ( "bufio" "bytes" "crypto/sha256" "encoding/base64" "errors" "fmt" "io" Loading @@ -12,6 +14,7 @@ import ( "path" "strings" "git.lyda.ie/pp/bulletin/storage" "github.com/adrg/xdg" "golang.org/x/crypto/ssh" "golang.org/x/sys/unix" Loading Loading @@ -233,3 +236,96 @@ func Fetch(login, nickname, username string) string { return fmt.Sprintf("%d keys added.\n", keys) } } // AddDB adds an SSH key to both the authorized_keys file and the database. func AddDB(q *storage.Queries, login, public string) error { // First add to authorized_keys file (existing behavior). if err := Add(login, public); err != nil { return err } // Parse the key to extract fingerprint and components. public = strings.TrimSpace(public) theKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(public)) if err != nil { return err } h := sha256.Sum256(theKey.Marshal()) fp := "SHA256:" + base64.RawStdEncoding.EncodeToString(h[:]) keyLine := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(theKey))) parts := strings.SplitN(keyLine, " ", 3) keyType := parts[0] keyBase64 := "" if len(parts) >= 2 { keyBase64 = parts[1] } comment := "" if len(parts) >= 3 { comment = parts[2] } ctx := storage.Context() return q.AddSSHKey(ctx, storage.AddSSHKeyParams{ Fingerprint: fp, Login: login, KeyType: keyType, Pubkey: keyBase64, Comment: comment, }) } // ListDB returns SSH keys for a user from the database. func ListDB(q *storage.Queries, login string) ([]storage.SshKey, error) { ctx := storage.Context() return q.ListSSHKeysByLogin(ctx, login) } // DeleteDB removes an SSH key from both the authorized_keys file and the database. func DeleteDB(q *storage.Queries, fingerprint, public string) error { // Delete from authorized_keys file. if err := Delete(public); err != nil { return err } // Delete from database. ctx := storage.Context() return q.DeleteSSHKey(ctx, fingerprint) } // FetchDB fetches keys from a forge and adds them to both file and DB. func FetchDB(q *storage.Queries, login, nickname, username string) string { sites := map[string]string{ "codeberg": "https://codeberg.org/%s.keys", "gitlab": "https://gitlab.com/%s.keys", "github": "https://github.com/%s.keys", } site := sites[strings.ToLower(nickname)] if site == "" { return fmt.Sprintln("ERROR: site nickname unknown.") } url := fmt.Sprintf(site, username) resp, err := http.Get(url) // #nosec G107 -- URL is constructed from a hardcoded allowlist of sites if err != nil { return fmt.Sprintf("ERROR: Failed to fetch ssh keys (%s).\n", err) } defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) keys := 0 for scanner.Scan() { keyline := string(bytes.TrimSpace(scanner.Bytes())) if err := AddDB(q, strings.ToUpper(login), keyline); err != nil { return fmt.Sprintf("ERROR: Failed to add key (%s).\n", err) } keys++ } switch keys { case 0: return fmt.Sprintln("No keys added.") case 1: return fmt.Sprintln("Key is added.") default: return fmt.Sprintf("%d keys added.\n", keys) } } main.go +68 −5 Original line number Diff line number Diff line Loading @@ -24,8 +24,11 @@ import ( "os" "strings" "git.lyda.ie/pp/bulletin/authorized" "git.lyda.ie/pp/bulletin/batch" "git.lyda.ie/pp/bulletin/onboard" "git.lyda.ie/pp/bulletin/repl" "git.lyda.ie/pp/bulletin/storage" "git.lyda.ie/pp/bulletin/this" "github.com/urfave/cli/v3" Loading @@ -41,17 +44,58 @@ func main() { Name: "user", Aliases: []string{"u"}, Usage: "user to run bulletin as", Required: true, }, &cli.StringFlag{ Name: "batch", Aliases: []string{"b"}, Usage: "batch command", }, &cli.StringFlag{ Name: "fp", Usage: "fingerprint of the connecting SSH key", }, &cli.BoolFlag{ Name: "onboard", Usage: "trigger onboarding mode for unknown SSH keys", }, &cli.StringFlag{ Name: "pubkey", Usage: "public key in type:base64 format (used during onboarding)", }, }, Commands: []*cli.Command{ { Name: "authorized-keys", Usage: "AuthorizedKeysCommand for sshd integration", ArgsUsage: "USERNAME KEYTYPE BASE64KEY", Action: func(_ context.Context, cmd *cli.Command) error { os.Exit(authorized.Run(cmd.Args().Slice())) return nil }, }, }, Action: func(_ context.Context, cmd *cli.Command) error { user := strings.ToUpper(cmd.String("user")) batchFlag := cmd.String("batch") fp := cmd.String("fp") onboardFlag := cmd.Bool("onboard") pubkeyFlag := cmd.String("pubkey") // Onboarding mode. if onboardFlag { if fp == "" || pubkeyFlag == "" { fmt.Println("ERROR: --onboard requires --fp and --pubkey.") os.Exit(1) } os.Exit(onboard.Run(fp, pubkeyFlag)) return nil } // All other modes require --user. if user == "" { fmt.Println("ERROR: --user is required.") os.Exit(1) } if batchFlag != "" { if user != "SYSTEM" { Loading @@ -68,13 +112,20 @@ func main() { exitcode = batch.Install() case "new-user": exitcode = batch.NewUser(cmd.Args().Slice()) case "migrate-keys": exitcode = batch.MigrateKeys() default: fmt.Println("ERROR: can only run batch commands as SYSTEM.") fmt.Println("ERROR: unknown batch command.") exitcode = 1 } os.Exit(exitcode) } // Update last_used_at if fingerprint is provided. if fp != "" { updateKeyLastUsed(fp) } err := this.StartThis(user) if err != nil { return err Loading @@ -92,3 +143,15 @@ func main() { fmt.Printf("ERROR: %s.\n", err) } } // updateKeyLastUsed touches the last_used_at timestamp for a key. func updateKeyLastUsed(fp string) { store, err := storage.Open() if err != nil { return } defer store.Close() q := storage.New(store.DB) ctx := storage.Context() _ = q.UpdateSSHKeyLastUsed(ctx, fp) } repl/accounts.go +25 −13 Original line number Diff line number Diff line Loading @@ -252,8 +252,8 @@ func ActionSSH(cmd *dclish.Command) error { } // ActionSSHAdd handles the `SSH ADD` command. This adds a given ssh key // to the authorized_keys file for the given user. An admin can add // a new public key for another user. // to the authorized_keys file and database for the given user. An admin // can add a new public key for another user. // // This is new to the Go version of BULLETIN. func ActionSSHAdd(cmd *dclish.Command) error { Loading @@ -275,7 +275,7 @@ func ActionSSHAdd(cmd *dclish.Command) error { fmt.Printf("ERROR: Failed to read ssh key (%s).\n", err) return nil } if err := key.Add(u.Login, sshkey); err != nil { if err := key.AddDB(this.Q, u.Login, sshkey); err != nil { fmt.Printf("ERROR: Failed to add key (%s).\n", err) return nil } Loading @@ -284,8 +284,8 @@ func ActionSSHAdd(cmd *dclish.Command) error { } // ActionSSHList handles the `SSH LIST` command. This lists all the // public keys for this user. An admin can list public keys for another // user. // public keys for this user from the database. An admin can list // public keys for another user. // // This is new to the Go version of BULLETIN. func ActionSSHList(cmd *dclish.Command) error { Loading @@ -302,18 +302,25 @@ func ActionSSHList(cmd *dclish.Command) error { fmt.Println("ERROR: User not found.") return nil } keys, err := key.List(login) keys, err := key.ListDB(this.Q, login) if err != nil { fmt.Printf("ERROR: Problem listing keys (%s).\n", err) return nil } fmt.Printf("The %d keys:\n %s\n", len(keys), strings.Join(keys, "\n ")) if len(keys) == 0 { fmt.Println("No keys found.") return nil } fmt.Printf("The %d keys:\n", len(keys)) for _, k := range keys { fmt.Printf(" %s %s %s (fp: %s)\n", k.KeyType, k.Pubkey, k.Comment, k.Fingerprint) } return nil } // ActionSSHDelete handles the `SSH DELETE` command. Removes ssh public // keys for a user. And admin can specify a different user to remove // public keys for. // keys for a user from both the file and database. An admin can specify // a different user to remove public keys for. // // This is new to the Go version of BULLETIN. func ActionSSHDelete(cmd *dclish.Command) error { Loading @@ -330,7 +337,7 @@ func ActionSSHDelete(cmd *dclish.Command) error { fmt.Println("ERROR: User not found.") return nil } keys, err := key.List(login) keys, err := key.ListDB(this.Q, login) if err != nil { fmt.Printf("ERROR: Problem listing keys (%s).\n", err) return nil Loading @@ -339,7 +346,11 @@ func ActionSSHDelete(cmd *dclish.Command) error { fmt.Println("No keys to delete.") return nil } choice, err := ask.Choose("Choose a key to delete:", keys) labels := make([]string, len(keys)) for i, k := range keys { labels[i] = fmt.Sprintf("%s %s %s", k.KeyType, k.Pubkey, k.Comment) } choice, err := ask.Choose("Choose a key to delete:", labels) if err != nil { fmt.Printf("ERROR: Problem choosing key (%s).\n", err) return nil Loading @@ -348,7 +359,8 @@ func ActionSSHDelete(cmd *dclish.Command) error { fmt.Println("Aborted.") return nil } err = key.Delete(keys[choice]) publine := fmt.Sprintf("%s %s", keys[choice].KeyType, keys[choice].Pubkey) err = key.DeleteDB(this.Q, keys[choice].Fingerprint, publine) if err != nil { fmt.Printf("ERROR: Problem deleting key (%s).\n", err) return nil Loading Loading @@ -376,6 +388,6 @@ func ActionSSHFetch(cmd *dclish.Command) error { sitename = cmd.Args[1] username = cmd.Args[2] } fmt.Print(key.Fetch(login, sitename, username)) fmt.Print(key.FetchDB(this.Q, login, sitename, username)) return nil } repl/command.go +26 −2 Original line number Diff line number Diff line Loading @@ -1130,11 +1130,35 @@ The following options are available: ACCESS ALWAYS BRIEF DEFAULT_EXPIRE EXPIRE_LIMIT FOLDER NOACCESS NOALWAYS NOBRIEF NOPROMPT_EXPIRE NOREADNEW NOSHOWNEW NOSYSTEM PROMPT_EXPIRE READNEW SHOWNEW SYSTEM NOSYSTEM PROMPT_EXPIRE READNEW REGISTRATION SHOWNEW SHIBBOLETH SYSTEM `, Action: ActionSet, Commands: dclish.Commands{ "REGISTRATION": { Description: `Controls open registration. When open, unknown SSH keys can self-onboard via the AuthorizedKeysCommand flow. Format: SET REGISTRATION OPEN|CLOSED This is a privileged command.`, MinArgs: 1, MaxArgs: 1, Action: ActionSetRegistration, }, "SHIBBOLETH": { Description: `Manages shibboleth challenge questions. When shibboleths are set, new users must answer all questions correctly before being allowed to create an account during onboarding. Format: SET SHIBBOLETH Enters an interactive menu for adding, listing, and deleting shibboleth questions. This is a privileged command.`, Action: ActionSetShibboleth, }, "NOACCESS": { Description: `This removes access for users. Loading Loading
batch/batch.go +164 −1 Original line number Diff line number Diff line Loading @@ -17,7 +17,11 @@ package batch import ( "bufio" "bytes" "crypto/sha256" _ "embed" "encoding/base64" "errors" "fmt" "os" Loading @@ -31,6 +35,7 @@ import ( "git.lyda.ie/pp/bulletin/storage" "git.lyda.ie/pp/bulletin/users" "github.com/adrg/xdg" "golang.org/x/crypto/ssh" ) //go:embed crontab Loading Loading @@ -110,7 +115,11 @@ the first user.`) if err != nil { system.ExpireLimit = 365 } err = q.SetSystem(ctx, system.Name, system.DefaultExpire, system.ExpireLimit) err = q.SetSystem(ctx, storage.SetSystemParams{ Name: system.Name, DefaultExpire: system.DefaultExpire, ExpireLimit: system.ExpireLimit, }) ask.CheckErr(err) login, err := ask.GetLine("Enter login of initial user: ") Loading Loading @@ -167,6 +176,57 @@ the first user.`) err = installCrontab(crontab.String()) ask.CheckErr(err) // Open registration setup. openReg, err := ask.GetLine("Allow unknown SSH keys to create accounts? [y/n]: ") if err == nil && strings.HasPrefix(strings.ToLower(strings.TrimSpace(openReg)), "y") { err = q.UpdateOpenRegistration(ctx, 1) ask.CheckErr(err) fmt.Println("Open registration enabled.") // Shibboleth setup. addShib, err := ask.GetLine("Would you like to add shibboleth challenge questions? [y/n]: ") if err == nil && strings.HasPrefix(strings.ToLower(strings.TrimSpace(addShib)), "y") { for { question, err := ask.GetLine("Enter question: ") if err != nil { break } answer, err := ask.GetLine("Enter answer: ") if err != nil { break } err = q.AddShibboleth(ctx, strings.TrimSpace(question), strings.TrimSpace(answer)) ask.CheckErr(err) fmt.Println("Shibboleth added.") more, err := ask.GetLine("Add another? [y/n]: ") if err != nil || !strings.HasPrefix(strings.ToLower(strings.TrimSpace(more)), "y") { break } } } // Print sshd config block. fmt.Printf(` --- Add the following to /etc/ssh/sshd_config --- Match User %s PasswordAuthentication no PubkeyAuthentication yes PermitTTY yes X11Forwarding no AllowTcpForwarding no PermitTunnel no ForceCommand %s --ssh AuthorizedKeysCommand %s authorized-keys %%u %%t %%k AuthorizedKeysCommandUser %s --- Then run: sudo systemctl reload sshd --- `, os.Getenv("USER"), bulletin, bulletin, os.Getenv("USER")) _, _ = ask.GetLine("Press Enter to continue after configuring sshd...") } // Mark that install has happened. err = touch(touchfile) ask.CheckErr(err) Loading Loading @@ -210,3 +270,106 @@ func NewUser(args []string) int { } return 0 } // MigrateKeys reads the ~/.ssh/authorized_keys file and migrates // bulletin entries into the ssh_keys database table. func MigrateKeys() int { store, err := storage.Open() ask.CheckErr(err) q := storage.New(store.DB) if err := MigrateKeysWithQueries(q); err != nil { fmt.Printf("ERROR: %s\n", err) return 1 } return 0 } // MigrateKeysWithQueries migrates authorized_keys entries into the // ssh_keys database table using the provided query handle. func MigrateKeysWithQueries(q *storage.Queries) error { ctx := storage.Context() bulletin, err := os.Executable() if err != nil { return fmt.Errorf("failed to find bulletin binary: %w", err) } keyfile := path.Join(xdg.Home, ".ssh", "authorized_keys") f, err := os.Open(keyfile) // #nosec G304 -- path is constructed from xdg.Home if err != nil { return fmt.Errorf("failed to open %s: %w", keyfile, err) } defer f.Close() migrated := 0 skipped := 0 scanner := bufio.NewScanner(f) for scanner.Scan() { line := bytes.TrimSpace(scanner.Bytes()) if len(line) == 0 { continue } pubKey, _, options, _, err := ssh.ParseAuthorizedKey(line) if err != nil { fmt.Printf("WARNING: skipping unparseable line: %s\n", err) skipped++ continue } // Extract login from the command= option. login := "" for _, opt := range options { parts := strings.SplitN(opt, "=", 2) if len(parts) != 2 || parts[0] != "command" { continue } cmd := strings.Split(strings.Trim(parts[1], "\" "), " ") if len(cmd) >= 3 && cmd[0] == bulletin && cmd[1] == "-u" { login = cmd[2] } break } if login == "" { skipped++ continue } // Compute fingerprint and key details. h := sha256.Sum256(pubKey.Marshal()) fp := "SHA256:" + base64.RawStdEncoding.EncodeToString(h[:]) keyBytes := ssh.MarshalAuthorizedKey(pubKey) keyLine := strings.TrimSpace(string(keyBytes)) keyParts := strings.SplitN(keyLine, " ", 3) keyType := keyParts[0] keyBase64 := "" if len(keyParts) >= 2 { keyBase64 = keyParts[1] } comment := "" if len(keyParts) >= 3 { comment = keyParts[2] } err = q.AddSSHKey(ctx, storage.AddSSHKeyParams{ Fingerprint: fp, Login: login, KeyType: keyType, Pubkey: keyBase64, Comment: comment, }) if err != nil { fmt.Printf("WARNING: skipping key for %s: %s\n", login, err) skipped++ continue } migrated++ } if err := scanner.Err(); err != nil { return fmt.Errorf("error reading authorized_keys: %w", err) } fmt.Printf("Migrated %d keys, skipped %d.\n", migrated, skipped) return nil }
key/key.go +97 −1 Original line number Diff line number Diff line // Package key manages the authorized keys file. // Package key manages the authorized keys file and the ssh_keys database table. package key import ( "bufio" "bytes" "crypto/sha256" "encoding/base64" "errors" "fmt" "io" Loading @@ -12,6 +14,7 @@ import ( "path" "strings" "git.lyda.ie/pp/bulletin/storage" "github.com/adrg/xdg" "golang.org/x/crypto/ssh" "golang.org/x/sys/unix" Loading Loading @@ -233,3 +236,96 @@ func Fetch(login, nickname, username string) string { return fmt.Sprintf("%d keys added.\n", keys) } } // AddDB adds an SSH key to both the authorized_keys file and the database. func AddDB(q *storage.Queries, login, public string) error { // First add to authorized_keys file (existing behavior). if err := Add(login, public); err != nil { return err } // Parse the key to extract fingerprint and components. public = strings.TrimSpace(public) theKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(public)) if err != nil { return err } h := sha256.Sum256(theKey.Marshal()) fp := "SHA256:" + base64.RawStdEncoding.EncodeToString(h[:]) keyLine := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(theKey))) parts := strings.SplitN(keyLine, " ", 3) keyType := parts[0] keyBase64 := "" if len(parts) >= 2 { keyBase64 = parts[1] } comment := "" if len(parts) >= 3 { comment = parts[2] } ctx := storage.Context() return q.AddSSHKey(ctx, storage.AddSSHKeyParams{ Fingerprint: fp, Login: login, KeyType: keyType, Pubkey: keyBase64, Comment: comment, }) } // ListDB returns SSH keys for a user from the database. func ListDB(q *storage.Queries, login string) ([]storage.SshKey, error) { ctx := storage.Context() return q.ListSSHKeysByLogin(ctx, login) } // DeleteDB removes an SSH key from both the authorized_keys file and the database. func DeleteDB(q *storage.Queries, fingerprint, public string) error { // Delete from authorized_keys file. if err := Delete(public); err != nil { return err } // Delete from database. ctx := storage.Context() return q.DeleteSSHKey(ctx, fingerprint) } // FetchDB fetches keys from a forge and adds them to both file and DB. func FetchDB(q *storage.Queries, login, nickname, username string) string { sites := map[string]string{ "codeberg": "https://codeberg.org/%s.keys", "gitlab": "https://gitlab.com/%s.keys", "github": "https://github.com/%s.keys", } site := sites[strings.ToLower(nickname)] if site == "" { return fmt.Sprintln("ERROR: site nickname unknown.") } url := fmt.Sprintf(site, username) resp, err := http.Get(url) // #nosec G107 -- URL is constructed from a hardcoded allowlist of sites if err != nil { return fmt.Sprintf("ERROR: Failed to fetch ssh keys (%s).\n", err) } defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) keys := 0 for scanner.Scan() { keyline := string(bytes.TrimSpace(scanner.Bytes())) if err := AddDB(q, strings.ToUpper(login), keyline); err != nil { return fmt.Sprintf("ERROR: Failed to add key (%s).\n", err) } keys++ } switch keys { case 0: return fmt.Sprintln("No keys added.") case 1: return fmt.Sprintln("Key is added.") default: return fmt.Sprintf("%d keys added.\n", keys) } }
main.go +68 −5 Original line number Diff line number Diff line Loading @@ -24,8 +24,11 @@ import ( "os" "strings" "git.lyda.ie/pp/bulletin/authorized" "git.lyda.ie/pp/bulletin/batch" "git.lyda.ie/pp/bulletin/onboard" "git.lyda.ie/pp/bulletin/repl" "git.lyda.ie/pp/bulletin/storage" "git.lyda.ie/pp/bulletin/this" "github.com/urfave/cli/v3" Loading @@ -41,17 +44,58 @@ func main() { Name: "user", Aliases: []string{"u"}, Usage: "user to run bulletin as", Required: true, }, &cli.StringFlag{ Name: "batch", Aliases: []string{"b"}, Usage: "batch command", }, &cli.StringFlag{ Name: "fp", Usage: "fingerprint of the connecting SSH key", }, &cli.BoolFlag{ Name: "onboard", Usage: "trigger onboarding mode for unknown SSH keys", }, &cli.StringFlag{ Name: "pubkey", Usage: "public key in type:base64 format (used during onboarding)", }, }, Commands: []*cli.Command{ { Name: "authorized-keys", Usage: "AuthorizedKeysCommand for sshd integration", ArgsUsage: "USERNAME KEYTYPE BASE64KEY", Action: func(_ context.Context, cmd *cli.Command) error { os.Exit(authorized.Run(cmd.Args().Slice())) return nil }, }, }, Action: func(_ context.Context, cmd *cli.Command) error { user := strings.ToUpper(cmd.String("user")) batchFlag := cmd.String("batch") fp := cmd.String("fp") onboardFlag := cmd.Bool("onboard") pubkeyFlag := cmd.String("pubkey") // Onboarding mode. if onboardFlag { if fp == "" || pubkeyFlag == "" { fmt.Println("ERROR: --onboard requires --fp and --pubkey.") os.Exit(1) } os.Exit(onboard.Run(fp, pubkeyFlag)) return nil } // All other modes require --user. if user == "" { fmt.Println("ERROR: --user is required.") os.Exit(1) } if batchFlag != "" { if user != "SYSTEM" { Loading @@ -68,13 +112,20 @@ func main() { exitcode = batch.Install() case "new-user": exitcode = batch.NewUser(cmd.Args().Slice()) case "migrate-keys": exitcode = batch.MigrateKeys() default: fmt.Println("ERROR: can only run batch commands as SYSTEM.") fmt.Println("ERROR: unknown batch command.") exitcode = 1 } os.Exit(exitcode) } // Update last_used_at if fingerprint is provided. if fp != "" { updateKeyLastUsed(fp) } err := this.StartThis(user) if err != nil { return err Loading @@ -92,3 +143,15 @@ func main() { fmt.Printf("ERROR: %s.\n", err) } } // updateKeyLastUsed touches the last_used_at timestamp for a key. func updateKeyLastUsed(fp string) { store, err := storage.Open() if err != nil { return } defer store.Close() q := storage.New(store.DB) ctx := storage.Context() _ = q.UpdateSSHKeyLastUsed(ctx, fp) }
repl/accounts.go +25 −13 Original line number Diff line number Diff line Loading @@ -252,8 +252,8 @@ func ActionSSH(cmd *dclish.Command) error { } // ActionSSHAdd handles the `SSH ADD` command. This adds a given ssh key // to the authorized_keys file for the given user. An admin can add // a new public key for another user. // to the authorized_keys file and database for the given user. An admin // can add a new public key for another user. // // This is new to the Go version of BULLETIN. func ActionSSHAdd(cmd *dclish.Command) error { Loading @@ -275,7 +275,7 @@ func ActionSSHAdd(cmd *dclish.Command) error { fmt.Printf("ERROR: Failed to read ssh key (%s).\n", err) return nil } if err := key.Add(u.Login, sshkey); err != nil { if err := key.AddDB(this.Q, u.Login, sshkey); err != nil { fmt.Printf("ERROR: Failed to add key (%s).\n", err) return nil } Loading @@ -284,8 +284,8 @@ func ActionSSHAdd(cmd *dclish.Command) error { } // ActionSSHList handles the `SSH LIST` command. This lists all the // public keys for this user. An admin can list public keys for another // user. // public keys for this user from the database. An admin can list // public keys for another user. // // This is new to the Go version of BULLETIN. func ActionSSHList(cmd *dclish.Command) error { Loading @@ -302,18 +302,25 @@ func ActionSSHList(cmd *dclish.Command) error { fmt.Println("ERROR: User not found.") return nil } keys, err := key.List(login) keys, err := key.ListDB(this.Q, login) if err != nil { fmt.Printf("ERROR: Problem listing keys (%s).\n", err) return nil } fmt.Printf("The %d keys:\n %s\n", len(keys), strings.Join(keys, "\n ")) if len(keys) == 0 { fmt.Println("No keys found.") return nil } fmt.Printf("The %d keys:\n", len(keys)) for _, k := range keys { fmt.Printf(" %s %s %s (fp: %s)\n", k.KeyType, k.Pubkey, k.Comment, k.Fingerprint) } return nil } // ActionSSHDelete handles the `SSH DELETE` command. Removes ssh public // keys for a user. And admin can specify a different user to remove // public keys for. // keys for a user from both the file and database. An admin can specify // a different user to remove public keys for. // // This is new to the Go version of BULLETIN. func ActionSSHDelete(cmd *dclish.Command) error { Loading @@ -330,7 +337,7 @@ func ActionSSHDelete(cmd *dclish.Command) error { fmt.Println("ERROR: User not found.") return nil } keys, err := key.List(login) keys, err := key.ListDB(this.Q, login) if err != nil { fmt.Printf("ERROR: Problem listing keys (%s).\n", err) return nil Loading @@ -339,7 +346,11 @@ func ActionSSHDelete(cmd *dclish.Command) error { fmt.Println("No keys to delete.") return nil } choice, err := ask.Choose("Choose a key to delete:", keys) labels := make([]string, len(keys)) for i, k := range keys { labels[i] = fmt.Sprintf("%s %s %s", k.KeyType, k.Pubkey, k.Comment) } choice, err := ask.Choose("Choose a key to delete:", labels) if err != nil { fmt.Printf("ERROR: Problem choosing key (%s).\n", err) return nil Loading @@ -348,7 +359,8 @@ func ActionSSHDelete(cmd *dclish.Command) error { fmt.Println("Aborted.") return nil } err = key.Delete(keys[choice]) publine := fmt.Sprintf("%s %s", keys[choice].KeyType, keys[choice].Pubkey) err = key.DeleteDB(this.Q, keys[choice].Fingerprint, publine) if err != nil { fmt.Printf("ERROR: Problem deleting key (%s).\n", err) return nil Loading Loading @@ -376,6 +388,6 @@ func ActionSSHFetch(cmd *dclish.Command) error { sitename = cmd.Args[1] username = cmd.Args[2] } fmt.Print(key.Fetch(login, sitename, username)) fmt.Print(key.FetchDB(this.Q, login, sitename, username)) return nil }
repl/command.go +26 −2 Original line number Diff line number Diff line Loading @@ -1130,11 +1130,35 @@ The following options are available: ACCESS ALWAYS BRIEF DEFAULT_EXPIRE EXPIRE_LIMIT FOLDER NOACCESS NOALWAYS NOBRIEF NOPROMPT_EXPIRE NOREADNEW NOSHOWNEW NOSYSTEM PROMPT_EXPIRE READNEW SHOWNEW SYSTEM NOSYSTEM PROMPT_EXPIRE READNEW REGISTRATION SHOWNEW SHIBBOLETH SYSTEM `, Action: ActionSet, Commands: dclish.Commands{ "REGISTRATION": { Description: `Controls open registration. When open, unknown SSH keys can self-onboard via the AuthorizedKeysCommand flow. Format: SET REGISTRATION OPEN|CLOSED This is a privileged command.`, MinArgs: 1, MaxArgs: 1, Action: ActionSetRegistration, }, "SHIBBOLETH": { Description: `Manages shibboleth challenge questions. When shibboleths are set, new users must answer all questions correctly before being allowed to create an account during onboarding. Format: SET SHIBBOLETH Enters an interactive menu for adding, listing, and deleting shibboleth questions. This is a privileged command.`, Action: ActionSetShibboleth, }, "NOACCESS": { Description: `This removes access for users. Loading