Unverified Commit a415178d authored by Kevin Lyda's avatar Kevin Lyda
Browse files

Implement the SSH and USER commands

parent 2d805312
Loading
Loading
Loading
Loading
+2 −2
Original line number Diff line number Diff line
@@ -35,11 +35,11 @@ Switch between MAIL and BULLETIN modes? MAIL commands are documented
  * Database
    * trigger to limit values for 'visibility'?
  * Add commands:
    * A way to add / delete ssh keys.
    * ~~A way to add / delete ssh keys.~~
    * A way to manage files?
    * Commands for a local mail system?
    * Commands to connect to Mattermost or mastodon?
    * Commands to manage users.
    * ~~Commands to manage users.~~
  * Handle MARK for SELECT and DIRECTORY.

Done:
+23 −0
Original line number Diff line number Diff line
@@ -5,8 +5,10 @@ getting a line of text, getting a choice from a liat and other things.
package ask

import (
	"errors"
	"fmt"
	"os"
	"strconv"

	"github.com/chzyer/readline"
)
@@ -29,3 +31,24 @@ func GetLine(prompt string) (string, error) {
	line, err := rl.Readline()
	return line, err
}

// Choose presents a list and asks a user to choose one.
func Choose(prompt string, choices []string) (int, error) {
	fmt.Println(prompt)
	for i := range choices {
		fmt.Printf("  %d. %s\n", i+1, choices[i])
	}
	response, err := GetLine("Choose a number or enter q to abort: ")
	if err != nil {
		return -1, err
	}
	choice, err := strconv.Atoi(response)
	if err != nil {
		return -1, nil
	}
	choice--
	if choice < 0 || choice >= len(choices) {
		return -1, errors.New("Choice out of bounds")
	}
	return choice, nil
}
+151 −4
Original line number Diff line number Diff line
@@ -2,24 +2,38 @@
package key

import (
	"bufio"
	"bytes"
	"errors"
	"fmt"
	"io"
	"os"
	"path"
	"strings"

	"github.com/adrg/xdg"
	"golang.org/x/crypto/ssh"
	"golang.org/x/sys/unix"
)

var keytemplate = `command="%s -u %s",no-port-forwarding,no-X11-forwarding,no-agent-forwarding %s\n`
var keytemplate = `command="%s -u %s",no-port-forwarding,no-X11-forwarding,no-agent-forwarding %s`

// Add adds an ssh key to the `authorized_keys` file.
func Add(login, public string) error {
	bulletin, err := os.Executable()
	// Parse and verify the key.
	public = strings.TrimSpace(public)
	theKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(public))
	if err != nil {
		return err
	}

	keyline := fmt.Sprintf(keytemplate, bulletin, login, public)
	// Find the bulletin binary.
	bulletin, err := os.Executable()
	if err != nil {
		return err
	}

	// File system management.
	sshdir := path.Join(xdg.Home, ".ssh")
	err = os.MkdirAll(sshdir, 0700)
	if err != nil {
@@ -27,11 +41,28 @@ func Add(login, public string) error {
	}
	keyfile := path.Join(sshdir, "authorized_keys")

	f, err := os.OpenFile(keyfile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600)
	// Open and lock the authorized_keys file.
	f, err := os.OpenFile(keyfile, os.O_RDWR|os.O_CREATE, 0600)
	if err != nil {
		return err
	}
	defer f.Close()
	if err := unix.Flock(int(f.Fd()), unix.LOCK_EX); err != nil {
		return err
	}
	defer unix.Flock(int(f.Fd()), unix.LOCK_UN) // unlock after we're done

	// Check for duplicates.
	keycontent, err := io.ReadAll(f)
	if err != nil {
		return err
	}
	if bytes.Contains(keycontent, ssh.MarshalAuthorizedKey(theKey)) {
		return errors.New("key already exists")
	}

	// Generate and write the key.
	keyline := fmt.Sprintf(keytemplate, bulletin, login, string(ssh.MarshalAuthorizedKey(theKey)))
	n, err := f.WriteString(keyline)
	if err != nil {
		return err
@@ -43,3 +74,119 @@ func Add(login, public string) error {

	return nil
}

// List returns a list of ssh keys for this user.
func List(login string) ([]string, error) {
	keys := []string{}

	// Find the bulletin binary.
	bulletin, err := os.Executable()
	if err != nil {
		return keys, err
	}

	// File system management.
	sshdir := path.Join(xdg.Home, ".ssh")
	err = os.MkdirAll(sshdir, 0700)
	if err != nil {
		return keys, err
	}
	keyfile := path.Join(sshdir, "authorized_keys")

	// Open the authorized_keys file.
	f, err := os.OpenFile(keyfile, os.O_RDWR|os.O_CREATE, 0600)
	if err != nil {
		return keys, err
	}
	defer f.Close()

	// look for lines.
	scanner := bufio.NewScanner(f)
	for scanner.Scan() {
		keyline := bytes.TrimSpace(scanner.Bytes())
		if len(keyline) == 0 {
			continue
		}
		public, _, options, _, err := ssh.ParseAuthorizedKey([]byte(keyline))
		if err != nil {
			return keys, err
		}
		for i := range options {
			opts := strings.SplitN(options[i], "=", 2)
			if len(opts) != 2 || opts[0] != "command" {
				continue
			}
			cmd := strings.Split(strings.Trim(opts[1], "\" "), " ")
			if len(cmd) != 3 {
				return keys, fmt.Errorf("Unexpected command in authorized keys file (%s)", opts[1])
			}
			if cmd[0] != bulletin {
				return keys, fmt.Errorf("Unexpected bulletin in authorized keys file (%s)", opts[1])
			}
			if cmd[1] != "-u" {
				return keys, fmt.Errorf("Unexpected flag in authorized keys file (%s)", opts[1])
			}
			if cmd[2] == login {
				keys = append(keys, strings.Trim(string(ssh.MarshalAuthorizedKey(public)), "\n"))
			}
			break
		}
	}

	return keys, nil
}

// Delete removes the key.
func Delete(public string) error {
	keys := []string{}

	// Parse and verify the key.
	public = strings.TrimSpace(public)
	doomedRaw, _, _, _, err := ssh.ParseAuthorizedKey([]byte(public))
	if err != nil {
		return err
	}

	// File system management.
	sshdir := path.Join(xdg.Home, ".ssh")
	err = os.MkdirAll(sshdir, 0700)
	if err != nil {
		return err
	}
	keyfile := path.Join(sshdir, "authorized_keys")

	// Open the authorized_keys file.
	f, err := os.OpenFile(keyfile, os.O_RDWR|os.O_CREATE, 0600)
	if err != nil {
		return err
	}
	defer f.Close()
	if err := unix.Flock(int(f.Fd()), unix.LOCK_EX); err != nil {
		return err
	}
	defer unix.Flock(int(f.Fd()), unix.LOCK_UN) // unlock after we're done

	// look for lines.
	doomed := doomedRaw.Marshal()
	scanner := bufio.NewScanner(f)
	for scanner.Scan() {
		keyline := bytes.TrimSpace(scanner.Bytes())
		if len(keyline) == 0 {
			continue
		}
		potential, _, _, _, err := ssh.ParseAuthorizedKey(keyline)
		if err != nil {
			return err
		}
		if bytes.Compare(potential.Marshal(), doomed) != 0 {
			keys = append(keys, string(keyline))
		}
	}
	if _, err := f.Seek(0, os.SEEK_SET); err != nil {
		return fmt.Errorf("seek: %w", err)
	}
	f.Truncate(0)
	f.WriteString(strings.Join(keys, "\n") + "\n")

	return nil
}
+226 −31
Original line number Diff line number Diff line
@@ -2,8 +2,14 @@ package repl

import (
	"fmt"
	"strings"

	"git.lyda.ie/kevin/bulletin/ask"
	"git.lyda.ie/kevin/bulletin/dclish"
	"git.lyda.ie/kevin/bulletin/key"
	"git.lyda.ie/kevin/bulletin/storage"
	"git.lyda.ie/kevin/bulletin/this"
	"git.lyda.ie/kevin/bulletin/users"
)

// ActionUser handles the `USER` command.
@@ -12,83 +18,272 @@ func ActionUser(cmd *dclish.Command) error {
	fmt.Println(`
The following commands are available:

  ADD        ADMIN      DELETE     DISABLE    ENABLE     MOD
  NOADMIN    NOMOD`)
  ADD        ADMIN      DELETE     DISABLE    ENABLE     LIST
  MOD        NOADMIN    NOMOD`)
	fmt.Println()
	return nil
}

// ActionUserAdd handles the `USER ADD` command.
func ActionUserAdd(_ *dclish.Command) error {
	// TODO: implement
func ActionUserAdd(cmd *dclish.Command) error {
	login := strings.ToUpper(cmd.Args[0])
	err := users.ValidLogin(login)
	if err != nil {
		fmt.Printf("ERROR: %s.\n", err)
		return nil
	}
	return nil
}

// ActionUserList handles the `USER LIST` command.
func ActionUserList(_ *dclish.Command) error {
	if this.User.Admin == 0 {
		fmt.Println("ERROR: You are not an admin.")
		return nil
	}
	ctx := storage.Context()
	userlist, err := this.Q.ListUsers(ctx)
	if err != nil {
		fmt.Printf("ERROR: Failed to list users (%s).\n", err)
		return nil
	}
	// TODO: nicer output for user.
	for _, u := range userlist {
		fmt.Printf("%s\n", u)
	}
	return nil
}

// ActionUserDelete handles the `USER DELETE` command.
func ActionUserDelete(_ *dclish.Command) error {
	// TODO: implement
func ActionUserDelete(cmd *dclish.Command) error {
	if this.User.Admin == 0 {
		fmt.Println("ERROR: You are not an admin.")
		return nil
	}
	u, err := users.ValidExistingLogin(this.Q, cmd.Args[0])
	if err != nil || u.Login == "" {
		fmt.Println("ERROR: User not found.")
		return nil
	}
	ctx := storage.Context()
	err = this.Q.DeleteUser(ctx, u.Login)
	if err != nil {
		fmt.Printf("ERROR: Failed to delete user (%s).\n", err)
		return nil
	}
	fmt.Println("User deleted.")
	return nil
}

// ActionUserEnable handles the `USER ENABLE` command.
func ActionUserEnable(_ *dclish.Command) error {
	// TODO: implement
func actionUserEnable(cmd *dclish.Command, disabled int64, doing string) error {
	if this.User.Admin == 0 {
		fmt.Println("ERROR: You are not an admin.")
		return nil
	}
	u, err := users.ValidExistingLogin(this.Q, cmd.Args[0])
	if err != nil || u.Login == "" {
		fmt.Println("ERROR: User not found.")
		return nil
	}
	if u.Disabled == disabled {
		fmt.Printf("User already %sd.\n", doing)
		return nil
	}
	ctx := storage.Context()
	err = this.Q.UpdateUserDisabled(ctx, storage.UpdateUserDisabledParams{
		Login:    u.Login,
		Disabled: disabled,
	})
	if err != nil {
		fmt.Printf("ERROR: Failed to %s user (%s).\n", doing, err)
		return nil
	}
	fmt.Printf("User %sd.\n", doing)
	return nil
}

// ActionUserEnable handles the `USER ENABLE` command.
func ActionUserEnable(cmd *dclish.Command) error {
	return actionUserEnable(cmd, 0, "enable")
}

// ActionUserDisable handles the `USER DISABLE` command.
func ActionUserDisable(_ *dclish.Command) error {
	// TODO: implement
func ActionUserDisable(cmd *dclish.Command) error {
	return actionUserEnable(cmd, 1, "disable")
}

func actionUserAdmin(cmd *dclish.Command, admin int64, doing string) error {
	if this.User.Admin == 0 {
		fmt.Println("ERROR: You are not an admin.")
		return nil
	}
	u, err := users.ValidExistingLogin(this.Q, cmd.Args[0])
	if err != nil || u.Login == "" {
		fmt.Println("ERROR: User not found.")
		return nil
	}
	if u.Admin == admin {
		fmt.Printf("User is already %s.\n", doing)
		return nil
	}
	ctx := storage.Context()
	err = this.Q.UpdateUserAdmin(ctx, storage.UpdateUserAdminParams{
		Login: u.Login,
		Admin: admin,
	})
	if err != nil {
		fmt.Printf("ERROR: Failed to make user %s (%s).\n", doing, err)
		return nil
	}
	fmt.Printf("User is now %s.\n", doing)
	return nil
}

// ActionUserAdmin handles the `USER ADMIN` command.
func ActionUserAdmin(_ *dclish.Command) error {
	// TODO: implement
	return nil
func ActionUserAdmin(cmd *dclish.Command) error {
	return actionUserAdmin(cmd, 1, "an admin")
}

// ActionUserNoadmin handles the `USER NOADMIN` command.
func ActionUserNoadmin(_ *dclish.Command) error {
	// TODO: implement
func ActionUserNoadmin(cmd *dclish.Command) error {
	return actionUserAdmin(cmd, 0, "not an admin")
}

func actionUserMod(cmd *dclish.Command, mod int64, doing string) error {
	if this.User.Admin == 0 {
		fmt.Println("ERROR: You are not an admin.")
		return nil
	}
	u, err := users.ValidExistingLogin(this.Q, cmd.Args[0])
	if err != nil || u.Login == "" {
		fmt.Println("ERROR: User not found.")
		return nil
	}
	if u.Moderator == mod {
		fmt.Printf("User is already %s.\n", doing)
		return nil
	}
	ctx := storage.Context()
	err = this.Q.UpdateUserMod(ctx, storage.UpdateUserModParams{
		Login:     u.Login,
		Moderator: mod,
	})
	if err != nil {
		fmt.Printf("ERROR: Failed to make user %s (%s).\n", doing, err)
		return nil
	}
	fmt.Printf("User is now %s.\n", doing)
	return nil
}

// ActionUserMod handles the `USER MOD` command.
func ActionUserMod(_ *dclish.Command) error {
	// TODO: implement
	return nil
func ActionUserMod(cmd *dclish.Command) error {
	return actionUserMod(cmd, 1, "a moderator")
}

// ActionUserNomod handles the `USER NOMOD` command.
func ActionUserNomod(_ *dclish.Command) error {
	// TODO: implement
	return nil
func ActionUserNomod(cmd *dclish.Command) error {
	return actionUserMod(cmd, 0, "not a moderator")
}

// ActionSSH handles the `SSH` command.
func ActionSSH(cmd *dclish.Command) error {
	fmt.Println(cmd.Description)
	fmt.Println(`\nThe following commands are available:
	fmt.Println(`
The following commands are available:

  ADD        DELETE     LIST`)
	fmt.Println()
	return nil
}

// ActionSSHAdd handles the `SSH ADD` command.
func ActionSSHAdd(_ *dclish.Command) error {
	// TODO: implement
func ActionSSHAdd(cmd *dclish.Command) error {
	if this.User.Admin == 0 && len(cmd.Args) == 1 {
		fmt.Println("ERROR: You are not an admin.")
		return nil
	}

// ActionSSHDelete handles the `SSH DELETE` command.
func ActionSSHDelete(_ *dclish.Command) error {
	// TODO: implement
	login := this.User.Login
	if len(cmd.Args) == 1 {
		login = cmd.Args[0]
	}
	u, err := users.ValidExistingLogin(this.Q, login)
	if err != nil || u.Login == "" {
		fmt.Println("ERROR: User not found.")
		return nil
	}
	sshkey, err := ask.GetLine("Enter ssh public key: ")
	if err != nil {
		fmt.Printf("ERROR: Failed to read ssh key (%s).\n", err)
		return nil
	}
	key.Add(login, sshkey)
	fmt.Println("Key is added.")
	return nil
}

// ActionSSHList handles the `SSH LIST` command.
func ActionSSHList(_ *dclish.Command) error {
	// TODO: implement
func ActionSSHList(cmd *dclish.Command) error {
	if this.User.Admin == 0 && len(cmd.Args) == 1 {
		fmt.Println("ERROR: You are not an admin.")
		return nil
	}
	login := this.User.Login
	if len(cmd.Args) == 1 {
		login = cmd.Args[0]
	}
	u, err := users.ValidExistingLogin(this.Q, login)
	if err != nil || u.Login == "" {
		fmt.Println("ERROR: User not found.")
		return nil
	}
	keys, err := key.List(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  "))
	return nil
}

// ActionSSHDelete handles the `SSH DELETE` command.
func ActionSSHDelete(cmd *dclish.Command) error {
	if this.User.Admin == 0 && len(cmd.Args) == 1 {
		fmt.Println("ERROR: You are not an admin.")
		return nil
	}
	login := this.User.Login
	if len(cmd.Args) == 1 {
		login = cmd.Args[0]
	}
	u, err := users.ValidExistingLogin(this.Q, login)
	if err != nil || u.Login == "" {
		fmt.Println("ERROR: User not found.")
		return nil
	}
	keys, err := key.List(login)
	if err != nil {
		fmt.Printf("ERROR: Problem listing keys (%s).\n", err)
		return nil
	}
	if len(keys) == 0 {
		fmt.Println("No keys to delete.")
		return nil
	}
	choice, err := ask.Choose("Choose a key to delete:", keys)
	if err != nil {
		fmt.Printf("ERROR: Problem choosing key (%s).\n", err)
		return nil
	}
	if choice < 0 {
		fmt.Println("Aborted.")
		return nil
	}
	err = key.Delete(keys[choice])
	if err != nil {
		fmt.Printf("ERROR: Problem deleting key (%s).\n", err)
		return nil
	}
	fmt.Println("Key deleted.")
	return nil
}
+27 −8
Original line number Diff line number Diff line
@@ -1141,6 +1141,10 @@ message.`,
				MaxArgs:     1,
				Action:      ActionUserNoadmin,
			},
			"LIST": {
				Description: `  Lists all the users.  Must be an admin.`,
				Action:      ActionUserList,
			},
			"MOD": {
				Description: `  Makes a user an mod.`,
				MinArgs:     1,
@@ -1160,21 +1164,36 @@ message.`,
		Action:      ActionSSH,
		Commands: dclish.Commands{
			"ADD": {
				Description: `  Adds an ssh key for a user.
				Description: `  Adds an  ssh key  for a user.  Only an  admin can add  an ssh  key for
  another user.

  Prompts the user for an ssh key and then adds it.

  Prompts the user for an ssh key and then adds it.`,
    Format:
      SSH ADD [login]`,
				Action:  ActionSSHAdd,
				MaxArgs: 1,
			},
			"DELETE": {
				Description: `  Removes an ssh key for a user.
				Description: `  Removes an ssh key for a user. Only an admin can remove an ssh key for
  another user.

  The user  is given a list  of current ssh  keys and is asked  which to
  remove.`,
  remove.

    Format:
      SSH DELETE [login]`,
				Action:  ActionSSHDelete,
				MaxArgs: 1,
			},
			"LIST": {
				Description: `  Prints a list of ssh keys for the user.`,
				Description: `  Prints a list of ssh keys for a  user. Only an admin can list ssh keys
  for another user.

    Format:
      SSH LIST [login]`,
				Action:  ActionSSHList,
				MaxArgs: 1,
			},
		},
	},
Loading