Files

675 lines
18 KiB
Go

package main
import (
"bufio"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"syscall"
)
const (
defaultPort = 64738
programName = "barnard-ui"
)
var (
bracketedAddressPattern = regexp.MustCompile(`^\[([^\]]+)\](?::([0-9]+))?$`)
hostPortPattern = regexp.MustCompile(`^(.+):([0-9]+)$`)
)
type Server struct {
Name string
Address string
Port int
Password string
Insecure bool
}
type ConfigError struct {
Line int
Content string
Problem string
}
func (e *ConfigError) Error() string {
return fmt.Sprintf("line %d: %s: %q", e.Line, e.Problem, e.Content)
}
type Paths struct {
ConfigDir string
CacheDir string
ServerFile string
CertFile string
BarnardTOML string
LogFile string
LogDir string
LogPrefsFile string
}
func default_paths(configDirOverride string) (Paths, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return Paths{}, err
}
configDir := filepath.Join(homeDir, ".config", "barnard")
if configDirOverride != "" {
configDir, err = filepath.Abs(configDirOverride)
if err != nil {
return Paths{}, err
}
}
cacheDir, err := os.UserCacheDir()
if err != nil {
return Paths{}, err
}
return Paths{
ConfigDir: configDir,
CacheDir: cacheDir,
ServerFile: filepath.Join(configDir, "servers.conf"),
CertFile: filepath.Join(configDir, "barnard.pem"),
BarnardTOML: filepath.Join(homeDir, ".barnard.toml"),
LogFile: filepath.Join(cacheDir, "barnard-ui.log"),
LogDir: filepath.Join(homeDir, "barnard-logs"),
LogPrefsFile: filepath.Join(configDir, "logging.conf"),
}, nil
}
type App struct {
ui *TerminalUI
paths Paths
servers map[string]Server
saveSessionLogs bool
}
func parse_port(raw string) (int, bool) {
port, err := strconv.Atoi(raw)
return port, err == nil && port >= 1 && port <= 65535
}
func parse_host_port(raw string) (string, int, bool) {
raw = strings.TrimSpace(raw)
if raw == "" || strings.ContainsAny(raw, "\r\n") {
return "", 0, false
}
if matches := bracketedAddressPattern.FindStringSubmatch(raw); matches != nil {
address := strings.TrimSpace(matches[1])
if address == "" {
return "", 0, false
}
if matches[2] == "" {
return address, defaultPort, true
}
port, valid := parse_port(matches[2])
if !valid {
return "", 0, false
}
return address, port, true
}
if matches := hostPortPattern.FindStringSubmatch(raw); matches != nil {
address := strings.TrimSpace(matches[1])
port, valid := parse_port(matches[2])
if address == "" || strings.Contains(address, ":") || !valid {
return "", 0, false
}
return address, port, true
}
if strings.Contains(raw, ":") {
return "", 0, false
}
return raw, defaultPort, true
}
func parse_server_input(raw string) (string, int, string, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", 0, "", false
}
password := ""
hostPort := raw
if before, after, found := strings.Cut(raw, "@"); found {
password = before
hostPort = after
}
if strings.ContainsAny(password, "\r\n") {
return "", 0, "", false
}
address, port, valid := parse_host_port(hostPort)
return address, port, password, valid
}
func normalize_insecure(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func error_content(raw string) string {
key, _, found := strings.Cut(raw, "=")
if found {
return strings.TrimSpace(key) + " = <redacted>"
}
return "<redacted>"
}
func load_servers(path string) (map[string]Server, []string, error) {
servers := make(map[string]Server)
file, err := os.Open(path)
if errors.Is(err, os.ErrNotExist) {
return servers, nil, nil
}
if err != nil {
return nil, nil, err
}
defer file.Close()
var current *Server
currentLine := 0
warnings := []string{}
finishServer := func(line int) error {
if current == nil {
return nil
}
if current.Name == "" {
return &ConfigError{Line: line, Content: "[server]", Problem: "server entry is missing a name"}
}
if current.Address == "" {
return &ConfigError{Line: line, Content: "[server]", Problem: "server entry is missing an address"}
}
if _, exists := servers[current.Name]; exists {
warnings = append(warnings, fmt.Sprintf("Duplicate server name %q near line %d; keeping the last entry.", current.Name, line))
}
servers[current.Name] = *current
return nil
}
scanner := bufio.NewScanner(file)
for lineNumber := 1; scanner.Scan(); lineNumber++ {
raw := scanner.Text()
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
if err := finishServer(currentLine); err != nil {
return nil, nil, err
}
if !strings.EqualFold(strings.TrimSpace(line[1:len(line)-1]), "server") {
return nil, nil, &ConfigError{Line: lineNumber, Content: raw, Problem: "unexpected section"}
}
current = &Server{Port: defaultPort}
currentLine = lineNumber
continue
}
if current == nil {
return nil, nil, &ConfigError{Line: lineNumber, Content: error_content(raw), Problem: "key outside of a [server] section"}
}
key, value, found := strings.Cut(raw, "=")
if !found {
return nil, nil, &ConfigError{Line: lineNumber, Content: error_content(raw), Problem: "expected key=value"}
}
key = strings.ToLower(strings.TrimSpace(key))
if key == "password" {
if strings.HasPrefix(value, " ") || strings.HasPrefix(value, "\t") {
value = value[1:]
}
} else {
value = strings.TrimSpace(value)
}
switch key {
case "name":
current.Name = value
case "address", "host":
current.Address = value
case "port":
port, valid := parse_port(value)
if !valid {
return nil, nil, &ConfigError{Line: lineNumber, Content: error_content(raw), Problem: "invalid port"}
}
current.Port = port
case "password":
current.Password = value
case "insecure":
current.Insecure = normalize_insecure(value)
default:
return nil, nil, &ConfigError{Line: lineNumber, Content: error_content(raw), Problem: fmt.Sprintf("unknown key %q", key)}
}
}
if err := scanner.Err(); err != nil {
return nil, nil, err
}
if err := finishServer(currentLine); err != nil {
return nil, nil, err
}
return servers, warnings, nil
}
func write_server_list(writer io.Writer, servers map[string]Server) error {
names := make([]string, 0, len(servers))
for name := range servers {
names = append(names, name)
}
sort.Strings(names)
if _, err := fmt.Fprintln(writer, "# barnard-ui server list"); err != nil {
return err
}
if _, err := fmt.Fprint(writer, "# Passwords are stored only when provided; this file is written with mode 0600.\n\n"); err != nil {
return err
}
for _, name := range names {
server := servers[name]
if strings.ContainsAny(server.Name+server.Address+server.Password, "\r\n") {
return fmt.Errorf("server %q contains a line break", name)
}
if _, err := fmt.Fprintf(writer, "[server]\nname = %s\naddress = %s\nport = %d\npassword = %s\ninsecure = %t\n\n",
server.Name, server.Address, server.Port, server.Password, server.Insecure); err != nil {
return err
}
}
return nil
}
func copy_file(source, destination string) error {
input, err := os.Open(source)
if err != nil {
return err
}
defer input.Close()
output, err := os.OpenFile(destination, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return err
}
if err := output.Chmod(0600); err != nil {
output.Close()
return err
}
_, copyErr := io.Copy(output, input)
closeErr := output.Close()
if copyErr != nil {
return copyErr
}
return closeErr
}
func save_servers(path string, servers map[string]Server) error {
directory := filepath.Dir(path)
if err := os.MkdirAll(directory, 0700); err != nil {
return err
}
temporary, err := os.CreateTemp(directory, filepath.Base(path)+".tmp-")
if err != nil {
return err
}
temporaryName := temporary.Name()
defer os.Remove(temporaryName)
if err := temporary.Chmod(0600); err != nil {
temporary.Close()
return err
}
if err := write_server_list(temporary, servers); err != nil {
temporary.Close()
return err
}
if err := temporary.Sync(); err != nil {
temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
if _, err := os.Stat(path); err == nil {
if err := copy_file(path, path+".bak"); err != nil {
return fmt.Errorf("create backup: %w", err)
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.Rename(temporaryName, path); err != nil {
return err
}
directoryHandle, err := os.Open(directory)
if err == nil {
_ = directoryHandle.Sync()
_ = directoryHandle.Close()
}
return nil
}
func sorted_server_names(servers map[string]Server) []string {
names := make([]string, 0, len(servers))
for name := range servers {
names = append(names, name)
}
sort.Strings(names)
return names
}
func clone_servers(servers map[string]Server) map[string]Server {
cloned := make(map[string]Server, len(servers))
for name, server := range servers {
cloned[name] = server
}
return cloned
}
func (app *App) add_server() error {
name, cancelled, err := app.ui.input("Enter a name for the new server:", "", false)
if err != nil || cancelled {
return err
}
name = strings.TrimSpace(name)
if name == "" || strings.ContainsAny(name, "\r\n") {
return app.ui.message("Server name cannot be empty or contain line breaks.")
}
rawAddress, cancelled, err := app.ui.input("Enter the server address. Add :port when it is not 64738.", "", false)
if err != nil || cancelled {
return err
}
address, port, shorthandPassword, valid := parse_server_input(rawAddress)
if !valid {
return app.ui.message("Invalid server address or port.")
}
password, cancelled, err := app.ui.input("Enter the server password, or leave it blank:", "", true)
if err != nil || cancelled {
return err
}
if strings.ContainsAny(password, "\r\n") {
return app.ui.message("Server password cannot contain line breaks.")
}
if password == "" {
password = shorthandPassword
}
insecure, err := app.ui.confirm("Skip server certificate verification for this server?")
if err != nil {
return err
}
if _, exists := app.servers[name]; exists {
overwrite, err := app.ui.confirm("A server named " + name + " already exists. Overwrite it?")
if err != nil || !overwrite {
return err
}
}
updatedServers := clone_servers(app.servers)
updatedServers[name] = Server{Name: name, Address: address, Port: port, Password: password, Insecure: insecure}
if err := save_servers(app.paths.ServerFile, updatedServers); err != nil {
return app.ui.message("Could not save server list: " + err.Error())
}
app.servers = updatedServers
app.log_line(fmt.Sprintf("Added server %s %s:%d", name, address, port))
return app.ui.message("Added server " + name)
}
func (app *App) remove_server() error {
if len(app.servers) == 0 {
return app.ui.message("No saved servers to remove.")
}
names := sorted_server_names(app.servers)
selection, cancelled, err := app.ui.menu(append(names, "Go Back"))
if err != nil || cancelled || selection == len(names) {
return err
}
name := names[selection]
confirmed, err := app.ui.confirm("Remove server " + name + "?")
if err != nil || !confirmed {
return err
}
updatedServers := clone_servers(app.servers)
delete(updatedServers, name)
if err := save_servers(app.paths.ServerFile, updatedServers); err != nil {
return app.ui.message("Could not save server list: " + err.Error())
}
app.servers = updatedServers
app.log_line("Removed server " + name)
return app.ui.message("Removed server " + name)
}
func config_has_value(path, wantedKey string) bool {
file, err := os.Open(path)
if err != nil {
return false
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, found := strings.Cut(line, "=")
if !found || !strings.EqualFold(strings.TrimSpace(key), wantedKey) {
continue
}
value = strings.TrimSpace(value)
return value != "" && value != `""` && value != "''"
}
return false
}
func format_address(address string, port int) string {
if strings.Contains(address, ":") {
return fmt.Sprintf("[%s]:%d", address, port)
}
return fmt.Sprintf("%s:%d", address, port)
}
func default_username() string {
user := os.Getenv("USER")
host, _ := os.Hostname()
switch {
case user != "" && host != "":
return user + "-" + host
case user != "":
return user
case host != "":
return host
default:
return "barnard"
}
}
func connection_args(server Server, paths Paths) ([]string, error) {
args := []string{"-server", format_address(server.Address, server.Port)}
if server.Insecure {
args = append(args, "-insecure")
}
if !config_has_value(paths.BarnardTOML, "username") {
args = append(args, "-username", default_username())
}
if _, err := os.Stat(paths.CertFile); err == nil && !config_has_value(paths.BarnardTOML, "certificate") {
args = append(args, "-certificate", paths.CertFile)
}
args = append(args, "--fifo", filepath.Join(paths.ConfigDir, "cmd"), "--buffers", "16")
return args, nil
}
func (app *App) connect() error {
if len(app.servers) == 0 {
return app.ui.message("No saved servers. Add a server first.")
}
names := sorted_server_names(app.servers)
selection, cancelled, err := app.ui.menu(append(names, "Go Back"))
if err != nil || cancelled || selection == len(names) {
return err
}
name := names[selection]
server := app.servers[name]
barnardPath, err := exec.LookPath("barnard")
if err != nil {
return app.ui.message("Required command not found: barnard")
}
args, err := connection_args(server, app.paths)
if err != nil {
return err
}
passwordFile := ""
if server.Password != "" {
passwordFile, err = write_password_file(app.paths.ConfigDir, server.Password)
if err != nil {
return app.ui.message("Could not prepare the server password: " + err.Error())
}
defer os.Remove(passwordFile)
args = append(args, "-password-file", passwordFile)
}
sessionLogFile := ""
if app.saveSessionLogs {
sessionLogFile, err = prepare_session_log(app.paths.LogDir, name)
if err != nil {
if messageErr := app.ui.message("Could not create session log: " + err.Error()); messageErr != nil {
return messageErr
}
} else {
args = append(args, "-log", "debug", "-logfile", sessionLogFile)
}
}
command := exec.Command(barnardPath, args...)
command.Stdin = os.Stdin
logHandle, err := os.OpenFile(app.paths.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return app.ui.message("Could not open launcher log: " + err.Error())
}
defer logHandle.Close()
outputWriters := []io.Writer{os.Stdout, logHandle}
if sessionLogFile != "" {
sessionOutput, err := os.OpenFile(sessionLogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return app.ui.message("Could not open session log: " + err.Error())
}
defer sessionOutput.Close()
outputWriters = append(outputWriters, sessionOutput)
}
commandOutput := io.MultiWriter(outputWriters...)
command.Stdout = commandOutput
command.Stderr = commandOutput
app.ui.close()
commandErr := run_external(app.ui, command)
var signalErr *terminalSignalError
if errors.As(commandErr, &signalErr) {
return signalErr
}
if err := app.ui.open(); err != nil {
return err
}
if receivedSignal := app.ui.take_termination(); receivedSignal != nil {
return &terminalSignalError{signal: receivedSignal}
}
if commandErr != nil {
return app.ui.message("Barnard exited with an error: " + commandErr.Error() + ". See log: " + app.paths.LogFile)
}
return nil
}
func (app *App) run() error {
for {
options := main_menu_options()
selection, cancelled, err := app.ui.menu(options)
if err != nil {
return err
}
if cancelled || selection == len(options)-1 {
return nil
}
switch options[selection] {
case "Connect":
err = app.connect()
case "Add server":
err = app.add_server()
case "Remove server":
err = app.remove_server()
case "Manage Certificate":
err = app.manage_certificate()
case "Logs":
err = app.manage_logs()
}
if err != nil {
return err
}
}
}
func main_menu_options() []string {
return []string{
"Connect",
"Add server",
"Remove server",
"Manage Certificate",
"Logs",
"Exit",
}
}
func run(arguments []string) error {
flags := flag.NewFlagSet(programName, flag.ContinueOnError)
configDir := flags.String("config-dir", "", "directory containing servers.conf and barnard.pem")
if err := flags.Parse(arguments); err != nil {
return err
}
if flags.NArg() != 0 {
return fmt.Errorf("unexpected arguments: %s", strings.Join(flags.Args(), " "))
}
paths, err := default_paths(*configDir)
if err != nil {
return err
}
if err := initialize_paths(paths); err != nil {
return err
}
servers, warnings, err := load_servers(paths.ServerFile)
if err != nil {
return fmt.Errorf("load %s: %w", paths.ServerFile, err)
}
ui, err := new_terminal_ui()
if err != nil {
return err
}
defer ui.shutdown()
app := &App{
ui: ui,
paths: paths,
servers: servers,
saveSessionLogs: load_logging_pref(paths.LogPrefsFile),
}
for _, warning := range warnings {
if err := app.ui.message(warning); err != nil {
return err
}
}
return app.run()
}
func main() {
if err := run(os.Args[1:]); err != nil {
if errors.Is(err, flag.ErrHelp) {
return
}
var signalErr *terminalSignalError
if errors.As(err, &signalErr) {
if receivedSignal, ok := signalErr.signal.(syscall.Signal); ok {
os.Exit(128 + int(receivedSignal))
}
}
fmt.Fprintln(os.Stderr, programName+":", err)
os.Exit(1)
}
}