Commit d4ffb481 authored by Kevin Lyda's avatar Kevin Lyda
Browse files

Add an open registration mode

Allow anonymous ssh connections which will allow users to create accounts
after answering some obvious account creation options and some admin defined
shibboleths.
parent 47f4c636
Loading
Loading
Loading
Loading
+164 −1
Original line number Diff line number Diff line
@@ -17,7 +17,11 @@
package batch

import (
	"bufio"
	"bytes"
	"crypto/sha256"
	_ "embed"
	"encoding/base64"
	"errors"
	"fmt"
	"os"
@@ -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
@@ -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: ")
@@ -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)
@@ -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
}
+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"
@@ -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"
@@ -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)
	}
}
+68 −5
Original line number Diff line number Diff line
@@ -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"
@@ -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" {
@@ -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
@@ -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)
}
+25 −13
Original line number Diff line number Diff line
@@ -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 {
@@ -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
	}
@@ -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 {
@@ -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 {
@@ -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
@@ -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
@@ -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
@@ -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
}
+26 −2
Original line number Diff line number Diff line
@@ -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