675 lines
18 KiB
Go
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)
|
|
}
|
|
}
|