git » chasquid » main » tree

[main] / cmd / chasquid-util / chasquid-util.go

// chasquid-util is a command-line utility for chasquid-related operations.

package main

import (
	"bytes"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"syscall"

	"blitiri.com.ar/go/chasquid/internal/config"
	"blitiri.com.ar/go/chasquid/internal/envelope"
	"blitiri.com.ar/go/chasquid/internal/localrpc"
	"blitiri.com.ar/go/chasquid/internal/normalize"
	"blitiri.com.ar/go/chasquid/internal/userdb"
	"golang.org/x/term"
	"google.golang.org/protobuf/encoding/prototext"
)

// Usage to show users on --help or invocation errors.
const usage = `
Usage:
  chasquid-util [options] user-add <user@domain> [--password=<password>] [--receive_only]
    Add a user to the userdb.
  chasquid-util [options] user-remove <user@domain>
    Remove a user from the userdb.
  chasquid-util [options] authenticate <user@domain> [--password=<password>]
    Authenticate a user.
  chasquid-util [options] check-userdb <domain>
    Check if the userdb for the given domain is accessible.
  chasquid-util [options] aliases-resolve <address>
    Resolve an address. Talks to the running chasquid.
  chasquid-util [options] domaininfo-remove <domain>
    Remove domaininfo for the given domain. Talks to the running chasquid.
  chasquid-util [options] print-config
    Print the current chasquid configuration.

  chasquid-util [options] dkim-keygen <domain> [<selector> <private-key.pem>] [--algo=rsa3072|rsa4096|ed25519]
    Generate a new DKIM key pair for the domain.
  chasquid-util [options] dkim-dns <domain> [<selector> <private-key.pem>]
    Print the DNS TXT record to use for the domain, selector and
    private key.

Options:
  -C=<path>, --configdir=<path>  Configuration directory
  -v                             Verbose mode
`

// Command-line arguments.
// Arguments starting with "-" will be parsed as key-value pairs, and
// positional arguments will appear as "$POS" -> value.
//
// For example, "--abc=def x y -p=q -r" will result in:
// {"--abc": "def", "$1": "x", "$2": "y", "-p": "q", "-r": ""}
var args map[string]string

// Globals, loaded from top-level options.
var (
	configDir = "/etc/chasquid"
)

func main() {
	args = parseArgs(usage)

	if _, ok := args["--help"]; ok {
		fmt.Print(usage)
		return
	}

	// Load globals.
	if d, ok := args["--configdir"]; ok {
		configDir = d
	}
	if d, ok := args["-C"]; ok {
		configDir = d
	}

	commands := map[string]func(){
		"user-add":          userAdd,
		"user-remove":       userRemove,
		"authenticate":      authenticate,
		"check-userdb":      checkUserDB,
		"aliases-resolve":   aliasesResolve,
		"print-config":      printConfig,
		"domaininfo-remove": domaininfoRemove,
		"dkim-keygen":       dkimKeygen,
		"dkim-dns":          dkimDNS,

		// These exist for testing purposes and may be removed in the future.
		// Do not rely on them.
		"dkim-verify": dkimVerify,
		"dkim-sign":   dkimSign,
	}

	cmd := args["$1"]
	if f, ok := commands[cmd]; ok {
		f()
	} else {
		fmt.Printf("Unknown argument %q\n", cmd)
		Fatalf(usage)
	}
}

// Fatalf prints the given message to stderr, then exits the program with an
// error code.
func Fatalf(s string, arg ...interface{}) {
	fmt.Fprintf(os.Stderr, s+"\n", arg...)
	os.Exit(1)
}

func userDBForDomain(domain string) string {
	if domain == "" {
		domain = args["$2"]
	}
	return configDir + "/domains/" + domain + "/users"
}

func userDBFromArgs(create bool) (string, string, *userdb.DB) {
	username := args["$2"]
	user, domain := envelope.Split(username)
	if domain == "" {
		Fatalf("Domain missing, username should be of the form 'user@domain'")
	}

	db, err := userdb.Load(userDBForDomain(domain))
	if err != nil {
		if create && os.IsNotExist(err) {
			fmt.Println("Creating database")
			err = os.MkdirAll(filepath.Dir(userDBForDomain(domain)), 0755)
			if err != nil {
				Fatalf("Error creating database dir: %v", err)
			}
		} else {
			Fatalf("Error loading database: %v", err)
		}
	}

	user, err = normalize.User(user)
	if err != nil {
		Fatalf("Error normalizing user: %v", err)
	}

	return user, domain, db
}

// chasquid-util check-userdb <domain>
func checkUserDB() {
	path := userDBForDomain("")
	// Check if the file exists. This is because userdb.Load does not consider
	// it an error.
	if _, err := os.Stat(path); os.IsNotExist(err) {
		Fatalf("Error: file %q does not exist", path)
	}

	udb, err := userdb.Load(path)
	if err != nil {
		Fatalf("Error loading database: %v", err)
	}

	fmt.Printf("Database loaded (%d users)\n", udb.Len())
}

// chasquid-util user-add <user@domain> [--password=<password>] [--receive_only]
func userAdd() {
	user, _, db := userDBFromArgs(true)

	_, recvOnly := args["--receive_only"]
	_, hasPassword := args["--password"]

	if recvOnly && hasPassword {
		Fatalf("Cannot specify both --receive_only and --password")
	}

	var err error
	if recvOnly {
		err = db.AddDeniedUser(user)
	} else {
		password := getPassword()
		err = db.AddUser(user, password)
	}

	if err != nil {
		Fatalf("Error adding user: %v", err)
	}

	err = db.Write()
	if err != nil {
		Fatalf("Error writing database: %v", err)
	}

	fmt.Println("Added user")
}

// chasquid-util authenticate <user@domain> [--password=<password>]
func authenticate() {
	user, _, db := userDBFromArgs(false)

	password := getPassword()
	ok := db.Authenticate(user, password)
	if ok {
		fmt.Println("Authentication succeeded")
	} else {
		Fatalf("Authentication failed")
	}
}

func getPassword() string {
	password, ok := args["--password"]
	if ok {
		return password
	}

	fmt.Printf("Password: ")
	p1, err := term.ReadPassword(syscall.Stdin)
	fmt.Printf("\n")
	if err != nil {
		Fatalf("Error reading password: %v\n", err)
	}

	fmt.Printf("Confirm password: ")
	p2, err := term.ReadPassword(syscall.Stdin)
	fmt.Printf("\n")
	if err != nil {
		Fatalf("Error reading password: %v", err)
	}

	if !bytes.Equal(p1, p2) {
		Fatalf("Passwords don't match")
	}

	return string(p1)
}

// chasquid-util user-remove <user@domain>
func userRemove() {
	user, _, db := userDBFromArgs(false)

	present := db.RemoveUser(user)
	if !present {
		Fatalf("Unknown user")
	}

	err := db.Write()
	if err != nil {
		Fatalf("Error writing database: %v", err)
	}

	fmt.Println("Removed user")
}

// chasquid-util aliases-resolve <address>
func aliasesResolve() {
	conf, err := config.Load(configDir+"/chasquid.conf", "")
	if err != nil {
		Fatalf("Error loading config: %v", err)
	}

	c := localrpc.NewClient(conf.DataDir + "/localrpc-v1")
	vs, err := c.Call("AliasResolve", "Address", args["$2"])
	if err != nil {
		Fatalf("Error resolving: %v", err)
	}

	// Result is a map of type -> []addresses.
	// Sort the types for deterministic output.
	ts := []string{}
	for t := range vs {
		ts = append(ts, t)
	}
	sort.Strings(ts)

	for _, t := range ts {
		for _, a := range vs[t] {
			fmt.Printf("%v  %s\n", t, a)
		}
	}
}

// chasquid-util print-config
func printConfig() {
	conf, err := config.Load(configDir+"/chasquid.conf", "")
	if err != nil {
		Fatalf("Error loading config: %v", err)
	}

	fmt.Println(prototext.Format(conf))
}

// chasquid-util domaininfo-remove <domain>
func domaininfoRemove() {
	conf, err := config.Load(configDir+"/chasquid.conf", "")
	if err != nil {
		Fatalf("Error loading config: %v", err)
	}

	c := localrpc.NewClient(conf.DataDir + "/localrpc-v1")
	_, err = c.Call("DomaininfoClear", "Domain", args["$2"])
	if err != nil {
		Fatalf("Error removing domaininfo entry: %v", err)
	}
}

// parseArgs parses the command line arguments, and returns a map.
//
// Arguments starting with "-" will be parsed as key-value pairs, and
// positional arguments will appear as "$POS" -> value.
//
// For example, "--abc=def x y -p=q -r" will result in:
// {"--abc": "def", "$1": "x", "$2": "y", "-p": "q", "-r": ""}
func parseArgs(usage string) map[string]string {
	args := map[string]string{}

	pos := 1
	for _, a := range os.Args[1:] {
		// Note: Consider handling end of args marker "--" explicitly in
		// the future if needed.
		if strings.HasPrefix(a, "-") {
			sp := strings.SplitN(a, "=", 2)
			if len(sp) < 2 {
				args[a] = ""
			} else {
				args[sp[0]] = sp[1]
			}
		} else {
			args["$"+strconv.Itoa(pos)] = a
			pos++
		}
	}

	return args
}