diff --git a/README.md b/README.md index eebe5e6..38ec942 100644 --- a/README.md +++ b/README.md @@ -129,14 +129,22 @@ If you want to do more complex things, write a shell script (or c application, p ## Connecting Via Text Interface You can now manage your server lists in a text GUI. -An Ncurses interface has been created by members of the [F123 Group](https://gitlab.com/f123). -Make sure the folder in which you store the barnard binary is in your path. This should be the default for any f123 user. -Then just run ./barnard-ui from this folder, and follow the instructions. -You can add barnard-ui to your path as well, and access it from anywhere. +The native Go interface provides server, certificate, and log management +without requiring Python or GNU Dialog. Build and run it with: + +```sh +go build -o barnard-ui ./cmd/barnard-ui +./barnard-ui +``` + +Make sure the `barnard` binary is in your path before connecting. You can add +`barnard-ui` to your path as well and run it from anywhere. New installs start with an empty server list. Saved servers are stored in `~/.config/barnard/servers.conf` as INI-style `[server]` entries. If you save a server password, it is written to that file so Barnard can use it when connecting; the UI writes the file with mode `0600`. +The borderless menus use cursor-only selection so terminal screen readers +announce only the item being visited. ## Modifications diff --git a/barnard-ui b/barnard-ui deleted file mode 100755 index c4def65..0000000 --- a/barnard-ui +++ /dev/null @@ -1,956 +0,0 @@ -#!/usr/bin/env python3 -"""barnard-ui: text interface for managing Barnard servers and certificates. - -This is a Python reimplementation of the original shell UI. It keeps the -dialog-based interface (important for screen reader users) and the same -configuration file layout as the shell version. -""" - -import datetime -import gettext -import os -import re -import shutil -import subprocess -import sys -import tarfile -import threading -import time - - -# --------------------------------------------------------------------------- -# Localization -# --------------------------------------------------------------------------- - -def _setup_gettext(): - try: - return gettext.translation( - "barnard-ui", localedir="/usr/share/locale", fallback=True - ) - except Exception: - return gettext.NullTranslations() - - -_ = _setup_gettext().gettext - - -# --------------------------------------------------------------------------- -# Paths and global state -# --------------------------------------------------------------------------- - -_HOME = os.path.expanduser("~") -cache_dir = os.path.join(os.environ.get("XDG_CACHE_HOME", os.path.join(_HOME, ".cache"))) -config_dir = os.path.join(_HOME, ".config", "barnard") -server_file = os.path.join(config_dir, "servers.conf") -cert_file = os.path.join(config_dir, "barnard.pem") -log_file = os.path.join(cache_dir, "barnard-ui.log") -log_dir = os.path.join(_HOME, "barnard-logs") -log_prefs_file = os.path.join(config_dir, "logging.conf") - -session_log_file = "" -save_session_logs = False - -servers = {} - - -class Server: - def __init__(self): - self.name = "" - self.address = "" - self.port = "64738" - self.password = "" - self.insecure = "0" - - -class ConfigError(Exception): - """Raised when servers.conf contains an entry we refuse to interpret.""" - - def __init__(self, lineno, line, problem): - self.lineno = lineno - self.line = line - self.problem = problem - super().__init__( - "%s: line %d: %s: %r" % (server_file, lineno, problem, line) - ) - - -# --------------------------------------------------------------------------- -# Small helpers -# --------------------------------------------------------------------------- - -DIALOG_OPTS = ["--insecure", "--no-lines", "--visit-items"] - - -def run_dialog(args, capture_result=False): - """Run dialog. - - dialog draws its widgets on stderr and writes the selected value on stdout - when --stdout is used. Keep stderr attached to the terminal for display and - only capture stdout when a result is needed. - """ - cmd = ["dialog", "--clear"] + DIALOG_OPTS + args - if capture_result: - cmd.append("--stdout") - return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=None, text=True) - return subprocess.run(cmd) - - -def msgbox(message): - run_dialog(["--msgbox", message, "10", "72"]) - - -def yesno(question): - proc = run_dialog( - [ - "--backtitle", - _("Press 'Enter' for \"yes\" or 'Escape' for \"no\"."), - "--yesno", - question, - "10", - "80", - ], - capture_result=False, - ) - return proc.returncode == 0 - - -def inputbox(instructions, initial=""): - proc = run_dialog( - [ - "--backtitle", - _("Enter text and press enter."), - "--inputbox", - instructions, - "0", - "0", - initial, - ], - capture_result=True, - ) - if proc.returncode != 0: - return None - return proc.stdout.rstrip("\n") - - -def passwordbox(instructions, initial=""): - proc = run_dialog( - [ - "--backtitle", - _("Enter text and press enter."), - "--passwordbox", - instructions, - "0", - "0", - initial, - ], - capture_result=True, - ) - if proc.returncode != 0: - return None - return proc.stdout.rstrip("\n") - - -def menulist(options): - items = [] - for option in options: - items.extend([option, option]) - proc = run_dialog( - [ - "--backtitle", - _( - "Use the up and down arrow keys to find the option you want, " - "then press enter to select it." - ), - "--no-tags", - "--menu", - _("Please select one"), - "0", - "0", - "0", - ] - + items, - capture_result=True, - ) - if proc.returncode != 0: - return None - return proc.stdout.rstrip("\n") - - -def log(line): - with open(log_file, "a") as handle: - handle.write(line + "\n") - if session_log_file: - with open(session_log_file, "a") as handle: - handle.write(line + "\n") - - -def fatal(message): - log(message) - if shutil.which("dialog"): - msgbox(message) - else: - print(message, file=sys.stderr) - sys.exit(1) - - -def require_command(command, display=None): - if shutil.which(command) is None: - fatal(_("Required command not found:") + " " + (display or command)) - - -def sanitize_filename(value): - return re.sub(r"[^A-Za-z0-9_.-]", "_", value) - - -def field_is_valid(value): - return "\n" not in value and "\r" not in value - - -def port_is_valid(port): - return port.isdigit() and 1 <= int(port) <= 65535 - - -def format_address(address, port): - if ":" in address: - return "[%s]:%s" % (address, port) - return "%s:%s" % (address, port) - - -# --------------------------------------------------------------------------- -# servers.conf parsing and saving -# --------------------------------------------------------------------------- - -def parse_host_port(host_port): - host_port = host_port.strip() - if not host_port: - return None, None - - match = re.match(r"^\[([^\]]+)\](?::([0-9]+))?$", host_port) - if match: - address = match.group(1) - port = match.group(2) or "64738" - else: - match = re.match(r"^(.+):([0-9]+)$", host_port) - if match: - address = match.group(1) - port = match.group(2) - elif ":" in host_port: - return None, None - else: - address = host_port - port = "64738" - - address = address.strip() - if not address or not port_is_valid(port) or not field_is_valid(address): - return None, None - return address, port - - -def parse_server_input(raw): - raw = raw.strip() - if not raw: - return None - - if "@" in raw: - password, host_port = raw.split("@", 1) - else: - password, host_port = "", raw - - if not field_is_valid(password): - return None - parsed = parse_host_port(host_port) - if parsed == (None, None): - return None - address, port = parsed - return address, port, password - - -def _finalize_server(current, lineno, warnings): - if not current.name: - raise ConfigError(lineno, "[server]", _("server entry is missing a name")) - if current.name in servers: - warnings.append( - _("Duplicate server name '%s' (line %d); keeping the last entry.") - % (current.name, lineno) - ) - value = current.insecure.lower() - current.insecure = "1" if value in ("1", "true", "yes", "on") else "0" - servers[current.name] = current - - -def load_servers(): - global servers - servers = {} - warnings = [] - - if not os.path.isfile(server_file): - return warnings - - with open(server_file, "r", encoding="utf-8", errors="replace") as handle: - lines = handle.readlines() - - current = None - current_lineno = 0 - - for lineno, raw in enumerate(lines, 1): - line = raw.strip() - if not line or line.startswith("#"): - continue - - match = re.match(r"^\[([^\]]+)\]$", line) - if match: - if current is not None: - _finalize_server(current, current_lineno, warnings) - section = match.group(1).strip().lower() - if section != "server": - raise ConfigError( - lineno, raw.rstrip("\n"), _("unexpected section [%s]") % match.group(1) - ) - current = Server() - current_lineno = lineno - continue - - if current is None: - raise ConfigError( - lineno, raw.rstrip("\n"), _("key outside of a [server] section") - ) - - if "=" not in line: - raise ConfigError(lineno, raw.rstrip("\n"), _("expected key=value")) - - key, value = line.split("=", 1) - key = key.strip().lower() - - if key == "name": - current.name = value.strip() - elif key in ("address", "host"): - current.address = value.strip() - elif key == "port": - current.port = value.strip() - if not port_is_valid(current.port): - raise ConfigError( - lineno, raw.rstrip("\n"), _("invalid port '%s'") % current.port - ) - elif key == "password": - # Preserve trailing/leading spaces beyond the single separator space - # so passwords are not silently changed on a save/reload cycle. - current.password = value.lstrip() - elif key == "insecure": - current.insecure = value.strip() - else: - raise ConfigError( - lineno, raw.rstrip("\n"), _("unknown key '%s'") % key - ) - - if current is not None: - _finalize_server(current, current_lineno, warnings) - - return warnings - - -def _fsync_dir(path): - try: - fd = os.open(path, os.O_RDONLY) - try: - os.fsync(fd) - finally: - os.close(fd) - except OSError: - pass - - -def save_servers(): - lines = [ - "# barnard-ui server list", - "# Passwords are stored only when provided; this file is written with mode 0600.", - "", - ] - for name in sorted(servers): - server = servers[name] - lines.append("[server]") - lines.append("name = %s" % server.name) - lines.append("address = %s" % server.address) - lines.append("port = %s" % server.port) - lines.append("password = %s" % server.password) - lines.append("insecure = %s" % ("true" if server.insecure == "1" else "false")) - lines.append("") - content = "\n".join(lines) + "\n" - - tmp = server_file + ".tmp" - try: - fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - with os.fdopen(fd, "w") as handle: - handle.write(content) - handle.flush() - os.fsync(handle.fileno()) - except OSError: - try: - os.unlink(tmp) - except OSError: - pass - msgbox(_("Could not save server list.")) - return False - - # Keep a backup of the previous file before replacing it. - if os.path.exists(server_file): - try: - shutil.copy2(server_file, server_file + ".bak") - except OSError: - log(_("Could not create backup of %s") % server_file) - - try: - os.replace(tmp, server_file) - except OSError: - try: - os.unlink(tmp) - except OSError: - pass - msgbox(_("Could not save server list.")) - return False - - _fsync_dir(config_dir) - return True - - -# --------------------------------------------------------------------------- -# barnard.toml helper -# --------------------------------------------------------------------------- - -def config_has_nonempty_value(key, config_file=None): - config_file = config_file or os.path.join(_HOME, ".barnard.toml") - key = key.lower() - if not os.path.isfile(config_file): - return False - - with open(config_file, "r", encoding="utf-8", errors="replace") as handle: - for line in handle: - line = line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - current_key, value = line.split("=", 1) - if current_key.strip().lower() != key: - continue - value = value.strip() - if not value or value in ('""', "''"): - return False - return True - return False - - -# --------------------------------------------------------------------------- -# Server management -# --------------------------------------------------------------------------- - -def add_server(): - name = inputbox(_("Enter a name for the new server:")) - if name is None: - return - name = name.strip() - if not name: - msgbox(_("Server name cannot be empty.")) - return - if not field_is_valid(name): - msgbox(_("Server name cannot contain line breaks.")) - return - - raw = inputbox( - _( - "Enter the address of the server. If the port is not standard, " - "add it after a colon, like address:port." - ) - ) - if raw is None: - return - parsed = parse_server_input(raw) - if parsed is None: - msgbox(_("Invalid server address or port.")) - return - address, port, input_password = parsed - - password = passwordbox( - _("Enter the server password, or leave it blank if there is no password:") - ) - if password is None: - return - if password: - if not field_is_valid(password): - msgbox(_("Server password cannot contain line breaks.")) - return - input_password = password - - insecure = "1" if yesno(_("Skip server certificate verification for this server?")) else "0" - - if name in servers: - if not yesno( - _("A server named") + " " + name + " " + _("already exists. Overwrite it?") - ): - return - - server = Server() - server.name = name - server.address = address - server.port = port - server.password = input_password - server.insecure = insecure - servers[name] = server - - if save_servers(): - log("Added server %s %s:%s" % (name, address, port)) - msgbox(_("Added server") + " " + name) - - -def remove_server(): - if not servers: - msgbox(_("No saved servers to remove.")) - return - - names = sorted(servers) - name = menulist(names + [_("Go Back")]) - if name is None or name == _("Go Back"): - return - - if not yesno(_("Remove server") + " " + name + "?"): - return - - del servers[name] - if save_servers(): - log("Removed server %s" % name) - msgbox(_("Removed server") + " " + name) - - -def run_barnard(args): - try: - proc = subprocess.Popen( - ["barnard"] + args, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - ) - except OSError as error: - fatal(_("Could not run barnard:") + " " + str(error)) - - for line in proc.stdout: - log(line.rstrip("\n")) - proc.wait() - return proc.returncode - - -def connect(): - global session_log_file - - if not servers: - msgbox(_("No saved servers. Add a server first.")) - return - - names = sorted(servers) - name = menulist(names + [_("Go Back")]) - if name is None or name == _("Go Back"): - return - - require_command("barnard", "barnard") - server = servers[name] - - args = ["-server", format_address(server.address, server.port)] - if server.password: - args += ["-password", server.password] - if server.insecure == "1": - args.append("-insecure") - - if not config_has_nonempty_value("username"): - user = os.environ.get("USER", "") - host = os.uname().nodename - if user and host: - username = "%s-%s" % (user, host) - else: - username = user or host or "barnard" - args += ["-username", username] - - if os.path.isfile(cert_file) and not config_has_nonempty_value("certificate"): - args += ["-certificate", cert_file] - - session_log_file = "" - if save_session_logs: - safe_name = sanitize_filename(name) - try: - os.makedirs(log_dir, exist_ok=True) - except OSError: - msgbox(_("Could not create logs directory:") + " " + log_dir) - else: - session_log_file = os.path.join( - log_dir, "%s-%s.log" % (safe_name, datetime.date.today().isoformat()) - ) - try: - with open(session_log_file, "a"): - pass - except OSError: - msgbox(_("Could not write log file:") + " " + session_log_file) - session_log_file = "" - - if session_log_file: - args += ["-log", "debug", "-logfile", session_log_file] - - args += ["--fifo", os.path.join(config_dir, "cmd"), "--buffers", "16"] - - status = run_barnard(args) - session_log_file = "" - if status != 0: - msgbox( - _("Barnard exited with status") - + " %d. " % status - + _("See log:") - + " " - + log_file - ) - - -# --------------------------------------------------------------------------- -# Certificate management -# --------------------------------------------------------------------------- - -def generate_certificate(): - require_command("openssl", "openssl") - - if os.path.isfile(cert_file): - if not yesno( - _( - "A certificate already exists. Do you want to replace it? " - "This may affect your registered identity on servers." - ) - ): - return - - common_name = inputbox( - _("Enter a name for your certificate (e.g., your username):"), "barnard" - ) - if common_name is None: - return - common_name = common_name.strip() or "barnard" - - proc = subprocess.run( - [ - "openssl", - "req", - "-x509", - "-newkey", - "rsa:2048", - "-keyout", - cert_file, - "-out", - cert_file, - "-days", - "3650", - "-nodes", - "-subj", - "/CN=%s" % common_name, - ], - capture_output=True, - text=True, - ) - if proc.returncode == 0: - os.chmod(cert_file, 0o600) - msgbox(_("Certificate generated successfully.")) - else: - msgbox(_("Failed to generate certificate. Make sure openssl is installed.")) - - -def view_certificate(): - require_command("openssl", "openssl") - - if not os.path.isfile(cert_file): - msgbox(_("No certificate found.") + " " + cert_file) - return - - proc = subprocess.run( - [ - "openssl", - "x509", - "-in", - cert_file, - "-noout", - "-subject", - "-dates", - "-fingerprint", - ], - capture_output=True, - text=True, - ) - info = proc.stdout.strip() - if info: - msgbox(info) - else: - msgbox(_("Could not read certificate information.")) - - -def import_certificate(): - require_command("openssl", "openssl") - - path = inputbox( - _( - "Enter the full path to your certificate file " - "(PEM format with certificate and private key):" - ) - ) - if path is None: - return - path = os.path.expanduser(path) - if not path: - return - - if not os.path.isfile(path): - msgbox(_("File not found:") + " " + path) - return - - check_cert = subprocess.run( - ["openssl", "x509", "-in", path, "-noout"], capture_output=True, text=True - ) - if check_cert.returncode != 0: - msgbox(_("The file does not appear to be a valid PEM certificate.")) - return - - check_rsa = subprocess.run( - ["openssl", "rsa", "-in", path, "-check", "-noout"], - capture_output=True, - text=True, - ) - check_ec = subprocess.run( - ["openssl", "ec", "-in", path, "-check", "-noout"], - capture_output=True, - text=True, - ) - if check_rsa.returncode != 0 and check_ec.returncode != 0: - msgbox( - _( - "The file does not appear to contain a valid private key. " - "The certificate file must contain both the certificate and " - "private key." - ) - ) - return - - if os.path.isfile(cert_file): - if not yesno(_("A certificate already exists. Do you want to replace it?")): - return - - try: - shutil.copyfile(path, cert_file) - os.chmod(cert_file, 0o600) - msgbox(_("Certificate imported successfully.")) - except OSError: - msgbox(_("Failed to import certificate.")) - - -def manage_certificate(): - while True: - action = menulist( - [_("Generate"), _("View"), _("Import"), _("Go Back")] - ) - if action is None or action == _("Go Back") or action == "": - return - if action == _("Generate"): - generate_certificate() - elif action == _("View"): - view_certificate() - elif action == _("Import"): - import_certificate() - - -# --------------------------------------------------------------------------- -# Logging -# --------------------------------------------------------------------------- - -def load_logging_pref(): - global save_session_logs - save_session_logs = False - if not os.path.isfile(log_prefs_file): - return - try: - with open(log_prefs_file, "r", encoding="utf-8", errors="replace") as handle: - for line in handle: - line = line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - if key.strip().lower() != "savesessionlogs": - continue - value = value.strip().lower() - save_session_logs = value in ("1", "true", "yes") - except OSError: - pass - - -def save_logging_pref(): - tmp = log_prefs_file + ".tmp" - try: - with open(tmp, "w") as handle: - handle.write("saveSessionLogs=%s\n" % ("1" if save_session_logs else "0")) - os.chmod(tmp, 0o600) - os.replace(tmp, log_prefs_file) - except OSError: - try: - os.unlink(tmp) - except OSError: - pass - msgbox(_("Could not save logging preference.")) - return False - return True - - -def toggle_session_logging(): - global save_session_logs - if save_session_logs: - question = _("Session logging is currently enabled. Disable it?") - else: - question = _( - "Session logging is currently disabled. " - "Enable saving logs to the logs directory?" - ) - if yesno(question): - save_session_logs = not save_session_logs - save_logging_pref() - - -def send_logs(): - if shutil.which("wormhole") is None: - msgbox(_("Required command not found:") + " wormhole") - return - - if not os.path.isdir(log_dir) or not any( - name.endswith(".log") for name in os.listdir(log_dir) - ): - msgbox(_("No logs to send. Logs are saved to:") + " " + log_dir) - return - - bundle = os.path.join( - cache_dir, - "barnard-logs-%s.tar.gz" % datetime.datetime.now().strftime("%Y%m%d-%H%M%S"), - ) - try: - with tarfile.open(bundle, "w:gz") as tar: - tar.add(log_dir, arcname=".") - except OSError: - msgbox(_("Could not create log archive.")) - return - - proc = subprocess.Popen( - ["wormhole", "send", bundle], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - - output_lines = [] - code = [None] - - def reader(): - for line in proc.stdout: - output_lines.append(line) - match = re.search(r"\b[0-9]+-[a-z]+-[a-z]+\b", line) - if match and code[0] is None: - code[0] = match.group(0) - - reader_thread = threading.Thread(target=reader, daemon=True) - reader_thread.start() - - deadline = time.time() + 10 - while code[0] is None and time.time() < deadline and proc.poll() is None: - time.sleep(0.1) - - if code[0] is not None: - msgbox(_("Wormhole code:") + " " + code[0]) - proc.wait() - if proc.returncode == 0: - msgbox(_("Logs sent successfully.")) - else: - msgbox(_("Log transfer did not complete successfully.")) - else: - proc.kill() - proc.wait() - detail = "".join(output_lines[-3:]).strip() - if detail: - msgbox(_("Could not start wormhole transfer:") + " " + detail) - else: - msgbox(_("Could not start wormhole transfer.")) - - reader_thread.join(timeout=1) - try: - os.unlink(bundle) - except OSError: - pass - - -def manage_logs(): - while True: - label = _("Disable logs") if save_session_logs else _("Enable logs") - action = menulist([label, _("Send logs with wormhole"), _("Go Back")]) - if action is None or action == _("Go Back") or action == "": - return - if action == label: - toggle_session_logging() - elif action == _("Send logs with wormhole"): - send_logs() - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - -def main(): - require_command("dialog", "dialog") - - try: - warnings = load_servers() - except ConfigError as error: - fatal(str(error)) - - for warning in warnings: - msgbox(warning) - - load_logging_pref() - - while True: - action = menulist( - [ - _("Connect"), - _("Add server"), - _("Remove server"), - _("Manage Certificate"), - _("Logs"), - _("Exit"), - ] - ) - if action is None: - sys.exit(0) - if action == _("Connect"): - connect() - elif action == _("Add server"): - add_server() - elif action == _("Remove server"): - remove_server() - elif action == _("Manage Certificate"): - manage_certificate() - elif action == _("Logs"): - manage_logs() - elif action == _("Exit") or action == "": - sys.exit(0) - - -def init_dirs(): - try: - os.makedirs(cache_dir, exist_ok=True) - os.makedirs(config_dir, exist_ok=True) - except OSError as error: - print("Could not create Barnard configuration directories: %s" % error, file=sys.stderr) - sys.exit(1) - try: - with open(log_file, "w"): - pass - except OSError: - print("Could not write log file: %s" % log_file, file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - init_dirs() - if os.environ.get("BARNARD_UI_TESTING", "0") != "1": - main() diff --git a/cmd/barnard-ui/certificates.go b/cmd/barnard-ui/certificates.go new file mode 100644 index 0000000..2f60ccc --- /dev/null +++ b/cmd/barnard-ui/certificates.go @@ -0,0 +1,234 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func openssl_path() (string, error) { + path, err := exec.LookPath("openssl") + if err != nil { + return "", fmt.Errorf("required command not found: openssl") + } + return path, nil +} + +func openssl_subject_name(commonName string) (string, error) { + if strings.ContainsAny(commonName, "\x00\r\n") { + return "", fmt.Errorf("certificate name cannot contain control characters") + } + escaped := strings.NewReplacer(`\`, `\\`, `/`, `\/`).Replace(commonName) + return "/CN=" + escaped, nil +} + +func install_private_bytes(path string, contents []byte) error { + return write_private_file(path, func(writer io.Writer) error { + _, err := writer.Write(contents) + return err + }) +} + +func (app *App) generate_certificate() error { + openssl, err := openssl_path() + if err != nil { + return app.ui.message(err.Error()) + } + if _, err := os.Stat(app.paths.CertFile); err == nil { + replace, err := app.ui.confirm("A certificate already exists. Replace it? This may affect your registered identity on servers.") + if err != nil || !replace { + return err + } + } else if !os.IsNotExist(err) { + return app.ui.message("Could not inspect the certificate: " + err.Error()) + } + + commonName, cancelled, err := app.ui.input("Enter a name for your certificate, such as your username:", "barnard", false) + if err != nil || cancelled { + return err + } + commonName = strings.TrimSpace(commonName) + if commonName == "" { + commonName = "barnard" + } + subject, err := openssl_subject_name(commonName) + if err != nil { + return app.ui.message(err.Error()) + } + + keyFile, err := os.CreateTemp(app.paths.ConfigDir, ".certificate-key-") + if err != nil { + return app.ui.message("Failed to create a certificate: " + err.Error()) + } + keyPath := keyFile.Name() + keyFile.Close() + defer os.Remove(keyPath) + certificateFile, err := os.CreateTemp(app.paths.ConfigDir, ".certificate-public-") + if err != nil { + return app.ui.message("Failed to create a certificate: " + err.Error()) + } + certificatePath := certificateFile.Name() + certificateFile.Close() + defer os.Remove(certificatePath) + + command := exec.Command(openssl, "req", "-x509", "-newkey", "rsa:2048", "-keyout", keyPath, "-out", certificatePath, "-days", "3650", "-nodes", "-subj", subject) + if output, err := command.CombinedOutput(); err != nil { + app.log_line("OpenSSL certificate generation failed: " + strings.TrimSpace(string(output))) + return app.ui.message("Failed to generate certificate.") + } + privateKey, err := os.ReadFile(keyPath) + if err != nil { + return app.ui.message("Failed to read generated private key: " + err.Error()) + } + certificate, err := os.ReadFile(certificatePath) + if err != nil { + return app.ui.message("Failed to read generated certificate: " + err.Error()) + } + combined := append(append(append([]byte(nil), privateKey...), '\n'), certificate...) + if err := install_private_bytes(app.paths.CertFile, combined); err != nil { + return app.ui.message("Failed to install generated certificate: " + err.Error()) + } + app.log_line("Generated certificate " + app.paths.CertFile) + return app.ui.message("Certificate generated successfully.") +} + +func (app *App) view_certificate() error { + openssl, err := openssl_path() + if err != nil { + return app.ui.message(err.Error()) + } + if _, err := os.Stat(app.paths.CertFile); os.IsNotExist(err) { + return app.ui.message("No certificate found: " + app.paths.CertFile) + } else if err != nil { + return app.ui.message("Could not inspect the certificate: " + err.Error()) + } + command := exec.Command(openssl, "x509", "-in", app.paths.CertFile, "-noout", "-subject", "-dates", "-fingerprint") + output, err := command.CombinedOutput() + if err != nil || strings.TrimSpace(string(output)) == "" { + return app.ui.message("Could not read certificate information.") + } + return app.ui.message(strings.TrimSpace(string(output))) +} + +func validate_certificate_pair(openssl, path string) error { + certificateCheck := exec.Command(openssl, "x509", "-in", path, "-noout") + if err := certificateCheck.Run(); err != nil { + return fmt.Errorf("the file does not contain a valid PEM certificate") + } + keyCheck := exec.Command(openssl, "pkey", "-in", path, "-check", "-noout") + if err := keyCheck.Run(); err != nil { + return fmt.Errorf("the file does not contain a valid private key") + } + certificatePublic, err := exec.Command(openssl, "x509", "-in", path, "-pubkey", "-noout").Output() + if err != nil { + return fmt.Errorf("could not read the certificate public key") + } + keyPublic, err := exec.Command(openssl, "pkey", "-in", path, "-pubout").Output() + if err != nil { + return fmt.Errorf("could not read the private key public key") + } + if !bytes.Equal(bytes.TrimSpace(certificatePublic), bytes.TrimSpace(keyPublic)) { + return fmt.Errorf("the certificate and private key do not match") + } + return nil +} + +func (app *App) import_certificate() error { + openssl, err := openssl_path() + if err != nil { + return app.ui.message(err.Error()) + } + rawPath, cancelled, err := app.ui.input("Enter the full path to a PEM file containing both the certificate and private key:", "", false) + if err != nil || cancelled { + return err + } + rawPath = strings.TrimSpace(rawPath) + if rawPath == "" { + return nil + } + path, err := expand_user_path(rawPath) + if err != nil { + return app.ui.message("Could not resolve certificate path: " + err.Error()) + } + info, err := os.Stat(path) + if os.IsNotExist(err) { + return app.ui.message("File not found: " + path) + } + if err != nil { + return app.ui.message("Could not inspect certificate file: " + err.Error()) + } + if !info.Mode().IsRegular() { + return app.ui.message("Certificate path is not a regular file: " + path) + } + contents, err := os.ReadFile(path) + if err != nil { + return app.ui.message("Failed to read certificate: " + err.Error()) + } + validationFile, err := os.CreateTemp(app.paths.ConfigDir, ".certificate-import-") + if err != nil { + return app.ui.message("Failed to prepare certificate validation: " + err.Error()) + } + validationPath := validationFile.Name() + defer os.Remove(validationPath) + if err := validationFile.Chmod(0600); err != nil { + validationFile.Close() + return app.ui.message("Failed to protect certificate validation file: " + err.Error()) + } + if _, err := validationFile.Write(contents); err != nil { + validationFile.Close() + return app.ui.message("Failed to prepare certificate validation: " + err.Error()) + } + if err := validationFile.Close(); err != nil { + return app.ui.message("Failed to prepare certificate validation: " + err.Error()) + } + if err := validate_certificate_pair(openssl, validationPath); err != nil { + return app.ui.message(err.Error() + ".") + } + if _, err := os.Stat(app.paths.CertFile); err == nil { + samePath := false + sourcePath, sourceErr := filepath.EvalSymlinks(path) + destinationPath, destinationErr := filepath.EvalSymlinks(app.paths.CertFile) + if sourceErr == nil && destinationErr == nil { + samePath = sourcePath == destinationPath + } + if samePath { + return app.ui.message("That certificate is already the active Barnard certificate.") + } + replace, err := app.ui.confirm("A certificate already exists. Replace it?") + if err != nil || !replace { + return err + } + } else if !os.IsNotExist(err) { + return app.ui.message("Could not inspect the existing certificate: " + err.Error()) + } + if err := install_private_bytes(app.paths.CertFile, contents); err != nil { + return app.ui.message("Failed to import certificate: " + err.Error()) + } + app.log_line("Imported certificate " + app.paths.CertFile) + return app.ui.message("Certificate imported successfully.") +} + +func (app *App) manage_certificate() error { + options := []string{"Generate", "View", "Import", "Go Back"} + for { + selection, cancelled, err := app.ui.menu(options) + if err != nil || cancelled || selection == len(options)-1 { + return err + } + switch options[selection] { + case "Generate": + err = app.generate_certificate() + case "View": + err = app.view_certificate() + case "Import": + err = app.import_certificate() + } + if err != nil { + return err + } + } +} diff --git a/cmd/barnard-ui/certificates_test.go b/cmd/barnard-ui/certificates_test.go new file mode 100644 index 0000000..94d3714 --- /dev/null +++ b/cmd/barnard-ui/certificates_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestOpenSSLSubjectNameEscapesSlashAndBackslash(t *testing.T) { + got, err := openssl_subject_name(`Example/User\Name`) + if err != nil { + t.Fatal(err) + } + want := `/CN=Example\/User\\Name` + if got != want { + t.Fatalf("subject = %q; want %q", got, want) + } +} + +func TestOpenSSLSubjectNameRejectsLineBreak(t *testing.T) { + if _, err := openssl_subject_name("Example\nUser"); err == nil { + t.Fatal("certificate subject accepted a line break") + } +} + +func TestValidateCertificatePairAcceptsMatchingPEM(t *testing.T) { + openssl, err := exec.LookPath("openssl") + if err != nil { + t.Skip("openssl is not installed") + } + directory := t.TempDir() + keyPath := filepath.Join(directory, "key.pem") + certificatePath := filepath.Join(directory, "certificate.pem") + combinedPath := filepath.Join(directory, "combined.pem") + command := exec.Command(openssl, "req", "-x509", "-newkey", "rsa:2048", "-keyout", keyPath, "-out", certificatePath, "-days", "1", "-nodes", "-subj", "/CN=Test") + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("generate test certificate: %v: %s", err, output) + } + key, err := os.ReadFile(keyPath) + if err != nil { + t.Fatal(err) + } + certificate, err := os.ReadFile(certificatePath) + if err != nil { + t.Fatal(err) + } + combined := append(append(append([]byte(nil), key...), '\n'), certificate...) + if err := os.WriteFile(combinedPath, combined, 0600); err != nil { + t.Fatal(err) + } + if err := validate_certificate_pair(openssl, combinedPath); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/barnard-ui/files.go b/cmd/barnard-ui/files.go new file mode 100644 index 0000000..6a97b0e --- /dev/null +++ b/cmd/barnard-ui/files.go @@ -0,0 +1,156 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" +) + +func initialize_paths(paths Paths) error { + if err := os.MkdirAll(paths.ConfigDir, 0700); err != nil { + return fmt.Errorf("create configuration directory: %w", err) + } + if err := os.MkdirAll(paths.CacheDir, 0700); err != nil { + return fmt.Errorf("create cache directory: %w", err) + } + logHandle, err := os.OpenFile(paths.LogFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("initialize launcher log: %w", err) + } + if err := logHandle.Chmod(0600); err != nil { + logHandle.Close() + return fmt.Errorf("protect launcher log: %w", err) + } + if err := logHandle.Close(); err != nil { + return fmt.Errorf("initialize launcher log: %w", err) + } + return nil +} + +func write_private_file(path string, writeContent func(io.Writer) error) 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 + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0600); err != nil { + temporary.Close() + return err + } + if err := writeContent(temporary); 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.Rename(temporaryPath, path); err != nil { + return err + } + directoryHandle, err := os.Open(directory) + if err == nil { + _ = directoryHandle.Sync() + _ = directoryHandle.Close() + } + return nil +} + +func write_password_file(configDir, password string) (string, error) { + if strings.ContainsAny(password, "\r\n") { + return "", fmt.Errorf("password cannot contain line breaks") + } + if err := os.MkdirAll(configDir, 0700); err != nil { + return "", err + } + file, err := os.CreateTemp(configDir, ".password-") + if err != nil { + return "", err + } + path := file.Name() + failed := true + defer func() { + if failed { + _ = os.Remove(path) + } + }() + if err := file.Chmod(0600); err != nil { + file.Close() + return "", err + } + if _, err := io.WriteString(file, password); err != nil { + file.Close() + return "", err + } + if err := file.Sync(); err != nil { + file.Close() + return "", err + } + if err := file.Close(); err != nil { + return "", err + } + failed = false + return path, nil +} + +func expand_user_path(path string) (string, error) { + if path != "~" && !strings.HasPrefix(path, "~/") { + return path, nil + } + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + if path == "~" { + return homeDir, nil + } + return filepath.Join(homeDir, strings.TrimPrefix(path, "~/")), nil +} + +func run_external(ui *TerminalUI, command *exec.Cmd) error { + if err := command.Start(); err != nil { + return err + } + done := make(chan error, 1) + go func() { + done <- command.Wait() + }() + select { + case err := <-done: + if receivedSignal := ui.take_termination(); receivedSignal != nil { + return &terminalSignalError{signal: receivedSignal} + } + var exitError *exec.ExitError + if errors.As(err, &exitError) { + if status, ok := exitError.Sys().(syscall.WaitStatus); ok && status.Signaled() { + return &terminalSignalError{signal: status.Signal()} + } + } + return err + case receivedSignal := <-ui.termination: + _ = command.Process.Signal(receivedSignal) + timer := time.NewTimer(3 * time.Second) + defer timer.Stop() + select { + case <-done: + case <-timer.C: + _ = command.Process.Kill() + <-done + } + return &terminalSignalError{signal: receivedSignal} + } +} diff --git a/cmd/barnard-ui/files_test.go b/cmd/barnard-ui/files_test.go new file mode 100644 index 0000000..c78120d --- /dev/null +++ b/cmd/barnard-ui/files_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + "time" +) + +func TestInitializePathsCreatesProtectedEmptyLog(t *testing.T) { + root := t.TempDir() + paths := Paths{ + ConfigDir: filepath.Join(root, "config"), + CacheDir: filepath.Join(root, "cache"), + LogFile: filepath.Join(root, "cache", "barnard-ui.log"), + } + if err := initialize_paths(paths); err != nil { + t.Fatal(err) + } + info, err := os.Stat(paths.LogFile) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 || info.Size() != 0 { + t.Fatalf("log mode and size = %o, %d; want 600, 0", info.Mode().Perm(), info.Size()) + } +} + +func TestRunExternalForwardsTerminationSignal(t *testing.T) { + sleep, err := exec.LookPath("sleep") + if err != nil { + t.Skip("sleep is not installed") + } + ui := &TerminalUI{termination: make(chan os.Signal, 1)} + ui.termination <- syscall.SIGTERM + started := time.Now() + err = run_external(ui, exec.Command(sleep, "30")) + var signalErr *terminalSignalError + if !errors.As(err, &signalErr) || signalErr.signal != syscall.SIGTERM { + t.Fatalf("run_external error = %v; want SIGTERM error", err) + } + if time.Since(started) > 5*time.Second { + t.Fatal("run_external did not promptly terminate the child") + } +} + +func TestRunExternalReportsChildSignal(t *testing.T) { + shell, err := exec.LookPath("sh") + if err != nil { + t.Skip("sh is not installed") + } + ui := &TerminalUI{termination: make(chan os.Signal, 1)} + err = run_external(ui, exec.Command(shell, "-c", "kill -TERM $$")) + var signalErr *terminalSignalError + if !errors.As(err, &signalErr) || signalErr.signal != syscall.SIGTERM { + t.Fatalf("run_external error = %v; want child SIGTERM error", err) + } +} + +func TestWritePasswordFilePreservesPasswordAndMode(t *testing.T) { + path, err := write_password_file(t.TempDir(), " secret value ") + if err != nil { + t.Fatal(err) + } + defer os.Remove(path) + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(contents) != " secret value " { + t.Fatalf("password contents = %q", string(contents)) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("password file mode = %o; want 600", info.Mode().Perm()) + } +} + +func TestExpandUserPath(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + got, err := expand_user_path("~/certificate.pem") + if err != nil { + t.Fatal(err) + } + want := filepath.Join(home, "certificate.pem") + if got != want { + t.Fatalf("expanded path = %q; want %q", got, want) + } +} diff --git a/cmd/barnard-ui/logging.go b/cmd/barnard-ui/logging.go new file mode 100644 index 0000000..1464180 --- /dev/null +++ b/cmd/barnard-ui/logging.go @@ -0,0 +1,275 @@ +package main + +import ( + "archive/tar" + "bufio" + "compress/gzip" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" +) + +var safeFilenamePattern = regexp.MustCompile(`[^A-Za-z0-9_.-]`) + +func (app *App) log_line(line string) { + handle, err := os.OpenFile(app.paths.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + return + } + defer handle.Close() + _, _ = fmt.Fprintln(handle, line) +} + +func load_logging_pref(path 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), "saveSessionLogs") { + continue + } + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "on": + return true + default: + return false + } + } + return false +} + +func save_logging_pref(path string, enabled bool) error { + return write_private_file(path, func(writer io.Writer) error { + value := 0 + if enabled { + value = 1 + } + _, err := fmt.Fprintf(writer, "saveSessionLogs=%d\n", value) + return err + }) +} + +func sanitize_filename(value string) string { + return safeFilenamePattern.ReplaceAllString(value, "_") +} + +func prepare_session_log(logDir, serverName string) (string, error) { + if err := os.MkdirAll(logDir, 0700); err != nil { + return "", err + } + path := filepath.Join(logDir, fmt.Sprintf("%s-%s.log", sanitize_filename(serverName), time.Now().Format("2006-01-02"))) + handle, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + return "", err + } + if err := handle.Chmod(0600); err != nil { + handle.Close() + return "", err + } + if err := handle.Close(); err != nil { + return "", err + } + return path, nil +} + +func log_files(logDir string) ([]string, error) { + entries, err := os.ReadDir(logDir) + if err != nil { + return nil, err + } + paths := []string{} + for _, entry := range entries { + if entry.Type().IsRegular() && strings.HasSuffix(entry.Name(), ".log") { + paths = append(paths, filepath.Join(logDir, entry.Name())) + } + } + return paths, nil +} + +func create_log_archive(logDir, cacheDir string) (string, error) { + paths, err := log_files(logDir) + if err != nil { + return "", err + } + if len(paths) == 0 { + return "", fs.ErrNotExist + } + if err := os.MkdirAll(cacheDir, 0700); err != nil { + return "", err + } + archiveFile, err := os.CreateTemp(cacheDir, "barnard-logs-*.tar.gz") + if err != nil { + return "", err + } + archivePath := archiveFile.Name() + failed := true + defer func() { + if failed { + _ = os.Remove(archivePath) + } + }() + if err := archiveFile.Chmod(0600); err != nil { + archiveFile.Close() + return "", err + } + gzipWriter := gzip.NewWriter(archiveFile) + tarWriter := tar.NewWriter(gzipWriter) + for _, path := range paths { + info, err := os.Stat(path) + if err != nil { + tarWriter.Close() + gzipWriter.Close() + archiveFile.Close() + return "", err + } + header, err := tar.FileInfoHeader(info, "") + if err != nil { + tarWriter.Close() + gzipWriter.Close() + archiveFile.Close() + return "", err + } + header.Name = filepath.Base(path) + header.Mode = 0600 + if err := tarWriter.WriteHeader(header); err != nil { + tarWriter.Close() + gzipWriter.Close() + archiveFile.Close() + return "", err + } + file, err := os.Open(path) + if err != nil { + tarWriter.Close() + gzipWriter.Close() + archiveFile.Close() + return "", err + } + _, copyErr := io.Copy(tarWriter, file) + closeErr := file.Close() + if copyErr != nil { + tarWriter.Close() + gzipWriter.Close() + archiveFile.Close() + return "", copyErr + } + if closeErr != nil { + tarWriter.Close() + gzipWriter.Close() + archiveFile.Close() + return "", closeErr + } + } + if err := tarWriter.Close(); err != nil { + gzipWriter.Close() + archiveFile.Close() + return "", err + } + if err := gzipWriter.Close(); err != nil { + archiveFile.Close() + return "", err + } + if err := archiveFile.Sync(); err != nil { + archiveFile.Close() + return "", err + } + if err := archiveFile.Close(); err != nil { + return "", err + } + failed = false + return archivePath, nil +} + +func (app *App) send_logs() error { + wormhole, err := exec.LookPath("wormhole") + if err != nil { + return app.ui.message("Required command not found: wormhole") + } + archivePath, err := create_log_archive(app.paths.LogDir, app.paths.CacheDir) + if errors.Is(err, fs.ErrNotExist) || errors.Is(err, os.ErrNotExist) { + return app.ui.message("No logs to send. Logs are saved to: " + app.paths.LogDir) + } + if err != nil { + return app.ui.message("Could not create log archive: " + err.Error()) + } + defer os.Remove(archivePath) + + if err := app.ui.message("Wormhole will display the transfer code in the normal terminal. Press Ctrl+C there to cancel the transfer."); err != nil { + return err + } + command := exec.Command(wormhole, "send", archivePath) + command.Stdin = os.Stdin + command.Stdout = os.Stdout + command.Stderr = os.Stderr + 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 { + app.log_line("Sent log archive with wormhole") + return app.ui.message("Logs sent successfully.") + } + return app.ui.message("Log transfer did not complete successfully: " + commandErr.Error()) +} + +func (app *App) toggle_session_logging() error { + question := "Session logging is currently disabled. Enable saving logs to the logs directory?" + if app.saveSessionLogs { + question = "Session logging is currently enabled. Disable it?" + } + confirmed, err := app.ui.confirm(question) + if err != nil || !confirmed { + return err + } + newValue := !app.saveSessionLogs + if err := save_logging_pref(app.paths.LogPrefsFile, newValue); err != nil { + return app.ui.message("Could not save logging preference: " + err.Error()) + } + app.saveSessionLogs = newValue + return nil +} + +func (app *App) manage_logs() error { + for { + toggleLabel := "Enable logs" + if app.saveSessionLogs { + toggleLabel = "Disable logs" + } + options := []string{toggleLabel, "Send logs with wormhole", "Go Back"} + selection, cancelled, err := app.ui.menu(options) + if err != nil || cancelled || selection == len(options)-1 { + return err + } + switch selection { + case 0: + err = app.toggle_session_logging() + case 1: + err = app.send_logs() + } + if err != nil { + return err + } + } +} diff --git a/cmd/barnard-ui/logging_test.go b/cmd/barnard-ui/logging_test.go new file mode 100644 index 0000000..2e2f18f --- /dev/null +++ b/cmd/barnard-ui/logging_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "archive/tar" + "compress/gzip" + "io" + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestLoggingPreferenceRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "config", "logging.conf") + if load_logging_pref(path) { + t.Fatal("missing logging preference must default to disabled") + } + if err := save_logging_pref(path, true); err != nil { + t.Fatal(err) + } + if !load_logging_pref(path) { + t.Fatal("saved logging preference was not loaded") + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("logging preference mode = %o; want 600", info.Mode().Perm()) + } +} + +func TestCreateLogArchiveIncludesOnlyRegularLogFiles(t *testing.T) { + root := t.TempDir() + logDir := filepath.Join(root, "logs") + cacheDir := filepath.Join(root, "cache") + if err := os.MkdirAll(logDir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(logDir, "first.log"), []byte("first\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(logDir, "ignore.txt"), []byte("ignore\n"), 0600); err != nil { + t.Fatal(err) + } + archivePath, err := create_log_archive(logDir, cacheDir) + if err != nil { + t.Fatal(err) + } + defer os.Remove(archivePath) + file, err := os.Open(archivePath) + if err != nil { + t.Fatal(err) + } + defer file.Close() + gzipReader, err := gzip.NewReader(file) + if err != nil { + t.Fatal(err) + } + defer gzipReader.Close() + tarReader := tar.NewReader(gzipReader) + names := []string{} + contents := "" + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + names = append(names, header.Name) + data, err := io.ReadAll(tarReader) + if err != nil { + t.Fatal(err) + } + contents += string(data) + } + if !reflect.DeepEqual(names, []string{"first.log"}) || contents != "first\n" { + t.Fatalf("archive contains names %v and data %q", names, contents) + } +} diff --git a/cmd/barnard-ui/main.go b/cmd/barnard-ui/main.go new file mode 100644 index 0000000..ac0ca4b --- /dev/null +++ b/cmd/barnard-ui/main.go @@ -0,0 +1,677 @@ +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) + " = " + } + return "" +} + +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() + case "About barnard-ui": + err = app.ui.message("barnard-ui is the native Go interface for managing Barnard servers, certificates, and logs. It does not require Python or GNU Dialog.") + } + if err != nil { + return err + } + } +} + +func main_menu_options() []string { + return []string{ + "Connect", + "Add server", + "Remove server", + "Manage Certificate", + "Logs", + "About barnard-ui", + "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) + } +} diff --git a/cmd/barnard-ui/main_test.go b/cmd/barnard-ui/main_test.go new file mode 100644 index 0000000..d8ef570 --- /dev/null +++ b/cmd/barnard-ui/main_test.go @@ -0,0 +1,212 @@ +package main + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestParseHostPort(t *testing.T) { + tests := []struct { + name string + input string + wantAddress string + wantPort int + wantValid bool + }{ + {name: "hostname", input: "example.com", wantAddress: "example.com", wantPort: 64738, wantValid: true}, + {name: "hostname and port", input: "example.com:64739", wantAddress: "example.com", wantPort: 64739, wantValid: true}, + {name: "bracketed IPv6", input: "[2001:db8::1]:64740", wantAddress: "2001:db8::1", wantPort: 64740, wantValid: true}, + {name: "bracketed IPv6 default port", input: "[2001:db8::1]", wantAddress: "2001:db8::1", wantPort: 64738, wantValid: true}, + {name: "unbracketed IPv6", input: "2001:db8::1", wantValid: false}, + {name: "invalid port", input: "example.com:70000", wantValid: false}, + {name: "non-numeric port", input: "example.com:abc", wantValid: false}, + {name: "empty", input: "", wantValid: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + address, port, valid := parse_host_port(test.input) + if valid != test.wantValid || address != test.wantAddress || port != test.wantPort { + t.Fatalf("parse_host_port(%q) = %q, %d, %t; want %q, %d, %t", + test.input, address, port, valid, test.wantAddress, test.wantPort, test.wantValid) + } + }) + } +} + +func TestParseServerInputPasswordShorthand(t *testing.T) { + address, port, password, valid := parse_server_input("secret@example.com:64739") + if !valid || address != "example.com" || port != 64739 || password != "secret" { + t.Fatalf("unexpected parse result: %q, %d, %q, %t", address, port, password, valid) + } +} + +func TestSaveAndLoadServersRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "barnard", "servers.conf") + want := map[string]Server{ + "Example": { + Name: "Example", + Address: "example.com", + Port: 64739, + Password: " secret value ", + Insecure: true, + }, + } + if err := save_servers(path, want); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("server file mode = %o; want 600", info.Mode().Perm()) + } + got, warnings, err := load_servers(path) + if err != nil { + t.Fatal(err) + } + if len(warnings) != 0 { + t.Fatalf("unexpected warnings: %v", warnings) + } + if got["Example"] != want["Example"] { + t.Fatalf("loaded server = %#v; want %#v", got["Example"], want["Example"]) + } +} + +func TestLoadServersStartsEmptyWhenFileIsMissing(t *testing.T) { + path := filepath.Join(t.TempDir(), "servers.conf") + servers, warnings, err := load_servers(path) + if err != nil { + t.Fatal(err) + } + if len(servers) != 0 { + t.Fatalf("new configuration contains %d default servers; want none", len(servers)) + } + if len(warnings) != 0 { + t.Fatalf("unexpected warnings: %v", warnings) + } +} + +func TestSaveServersCreatesBackup(t *testing.T) { + path := filepath.Join(t.TempDir(), "servers.conf") + if err := os.WriteFile(path, []byte("old contents\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path+".bak", []byte("older contents\n"), 0644); err != nil { + t.Fatal(err) + } + if err := save_servers(path, map[string]Server{}); err != nil { + t.Fatal(err) + } + backup, err := os.ReadFile(path + ".bak") + if err != nil { + t.Fatal(err) + } + if string(backup) != "old contents\n" { + t.Fatalf("backup = %q; want old contents", backup) + } + info, err := os.Stat(path + ".bak") + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("backup mode = %o; want 600", info.Mode().Perm()) + } +} + +func TestLoadServersRejectsUnknownKeys(t *testing.T) { + path := filepath.Join(t.TempDir(), "servers.conf") + contents := "[server]\nname = Example\naddress = example.com\nmystery = value\n" + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + _, _, err := load_servers(path) + if err == nil || !strings.Contains(err.Error(), "unknown key") { + t.Fatalf("load_servers error = %v; want unknown key", err) + } + if strings.Contains(err.Error(), "value") { + t.Fatalf("load_servers exposed configuration value: %v", err) + } +} + +func TestLoadServersRedactsMalformedLines(t *testing.T) { + path := filepath.Join(t.TempDir(), "servers.conf") + secret := "private-password-without-an-equals-sign" + contents := "[server]\nname = Example\naddress = example.com\n" + secret + "\n" + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + _, _, err := load_servers(path) + if err == nil { + t.Fatal("load_servers accepted a malformed line") + } + if strings.Contains(err.Error(), secret) { + t.Fatalf("load_servers exposed malformed configuration content: %v", err) + } +} + +func TestConfigHasValue(t *testing.T) { + path := filepath.Join(t.TempDir(), ".barnard.toml") + contents := "# username = ignored\nUsername = \"Example User\"\nCertificate = \"\"\n" + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + if !config_has_value(path, "username") { + t.Fatal("expected username to be present") + } + if config_has_value(path, "certificate") { + t.Fatal("empty certificate should not count as present") + } +} + +func TestFormatAddress(t *testing.T) { + if got := format_address("example.com", 64738); got != "example.com:64738" { + t.Fatalf("format_address hostname = %q", got) + } + if got := format_address("2001:db8::1", 64738); got != "[2001:db8::1]:64738" { + t.Fatalf("format_address IPv6 = %q", got) + } +} + +func TestDefaultPathsAcceptsIsolatedConfigDirectory(t *testing.T) { + configDir := filepath.Join(t.TempDir(), "isolated") + paths, err := default_paths(configDir) + if err != nil { + t.Fatal(err) + } + if paths.ConfigDir != configDir { + t.Fatalf("config directory = %q; want %q", paths.ConfigDir, configDir) + } + if paths.ServerFile != filepath.Join(configDir, "servers.conf") { + t.Fatalf("server file = %q", paths.ServerFile) + } +} + +func TestConnectionArgsNeverExposePassword(t *testing.T) { + server := Server{Name: "Private", Address: "example.com", Port: 64738, Password: "do not expose"} + args, err := connection_args(server, Paths{}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.Join(args, " "), server.Password) { + t.Fatalf("connection arguments expose password: %v", args) + } +} + +func TestProgramIdentityAndAboutMenuUseBarnardUI(t *testing.T) { + if programName != "barnard-ui" { + t.Fatalf("programName = %q", programName) + } + options := main_menu_options() + if !slices.Contains(options, "About barnard-ui") { + t.Fatalf("main menu lacks About barnard-ui: %v", options) + } + for _, option := range options { + if strings.Contains(option, "go-ui") { + t.Fatalf("legacy go-ui name remains in main menu: %q", option) + } + } +} diff --git a/cmd/barnard-ui/terminal_ui.go b/cmd/barnard-ui/terminal_ui.go new file mode 100644 index 0000000..ca69115 --- /dev/null +++ b/cmd/barnard-ui/terminal_ui.go @@ -0,0 +1,470 @@ +package main + +import ( + "errors" + "os" + "os/signal" + "strings" + "sync/atomic" + "syscall" + "unicode" + + "github.com/mattn/go-runewidth" + "github.com/nsf/termbox-go" +) + +type TerminalUI struct { + initialized atomic.Bool + signals chan os.Signal + termination chan os.Signal + stopSignals chan struct{} +} + +var errTerminalInterrupted = errors.New("terminal interface interrupted") + +type terminalSignalError struct { + signal os.Signal +} + +func (err *terminalSignalError) Error() string { + return "interrupted by " + err.signal.String() +} + +func new_terminal_ui() (*TerminalUI, error) { + ui := &TerminalUI{ + signals: make(chan os.Signal, 1), + termination: make(chan os.Signal, 1), + stopSignals: make(chan struct{}), + } + if err := ui.open(); err != nil { + return nil, err + } + signal.Notify(ui.signals, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) + go func() { + for { + select { + case receivedSignal := <-ui.signals: + select { + case ui.termination <- receivedSignal: + default: + } + if ui.initialized.Load() { + termbox.Interrupt() + } + case <-ui.stopSignals: + return + } + } + }() + return ui, nil +} + +func (ui *TerminalUI) open() error { + if ui.initialized.Load() { + return nil + } + if err := termbox.Init(); err != nil { + return err + } + termbox.SetInputMode(termbox.InputEsc) + ui.initialized.Store(true) + return nil +} + +func (ui *TerminalUI) close() { + if ui.initialized.Swap(false) { + termbox.Close() + } +} + +func (ui *TerminalUI) shutdown() { + signal.Stop(ui.signals) + close(ui.stopSignals) + ui.close() +} + +func (ui *TerminalUI) take_termination() os.Signal { + select { + case receivedSignal := <-ui.termination: + return receivedSignal + default: + return nil + } +} + +func safe_text_rune(value rune) rune { + if unicode.IsControl(value) || unicode.Is(unicode.Bidi_Control, value) { + return ' ' + } + return value +} + +func draw_text(x, y int, text string, foreground termbox.Attribute) { + width, height := termbox.Size() + if y < 0 || y >= height { + return + } + for _, character := range text { + if x >= width { + break + } + if x >= 0 { + character = safe_text_rune(character) + termbox.SetCell(x, y, character, foreground, termbox.ColorDefault) + } + characterWidth := runewidth.RuneWidth(character) + if characterWidth < 1 { + characterWidth = 1 + } + x += characterWidth + } +} + +func wrap_lines(text string, width int) []string { + if width < 1 { + width = 1 + } + result := []string{} + for _, paragraph := range strings.Split(text, "\n") { + words := strings.Fields(paragraph) + if len(words) == 0 { + result = append(result, "") + continue + } + line := "" + for _, word := range words { + for runewidth.StringWidth(word) > width { + if line != "" { + result = append(result, line) + line = "" + } + prefix := runewidth.Truncate(word, width, "") + if prefix == "" { + prefix = string([]rune(word)[0]) + } + result = append(result, prefix) + word = strings.TrimPrefix(word, prefix) + } + candidate := word + if line != "" { + candidate = line + " " + word + } + if runewidth.StringWidth(candidate) > width { + result = append(result, line) + line = word + } else { + line = candidate + } + } + result = append(result, line) + } + return result +} + +func begin_screen(instructions string) (int, int) { + termbox.Clear(termbox.ColorDefault, termbox.ColorDefault) + width, height := termbox.Size() + termbox.HideCursor() + draw_text(0, 0, instructions, termbox.ColorDefault|termbox.AttrBold) + return width, height +} + +func set_cursor(x, y, width, height int) { + if width < 1 || height < 1 { + termbox.HideCursor() + return + } + if x < 0 { + x = 0 + } else if x >= width { + x = width - 1 + } + if y < 0 { + y = 0 + } else if y >= height { + y = height - 1 + } + termbox.SetCursor(x, y) +} + +// choice_label deliberately leaves selection out of the terminal contents. +// Moving only the hardware cursor lets terminal screen readers announce the +// newly visited item without also announcing the item that lost selection. +func choice_label(label string, _ bool) string { + return label +} + +func (ui *TerminalUI) poll_key() (termbox.Event, error) { + for { + event := termbox.PollEvent() + switch event.Type { + case termbox.EventKey, termbox.EventResize: + return event, nil + case termbox.EventInterrupt: + select { + case receivedSignal := <-ui.termination: + return event, &terminalSignalError{signal: receivedSignal} + default: + return event, errTerminalInterrupted + } + case termbox.EventError: + return event, event.Err + } + } +} + +func (ui *TerminalUI) message(message string) error { + for { + width, height := begin_screen("Press Enter or Escape to continue.") + lines := wrap_lines(message, width) + for index, line := range lines { + draw_text(0, index+2, line, termbox.ColorDefault) + } + cursorY := 2 + if height <= cursorY { + cursorY = height - 1 + } + set_cursor(0, cursorY, width, height) + if err := termbox.Flush(); err != nil { + return err + } + event, err := ui.poll_key() + if err != nil { + return err + } + if event.Type == termbox.EventResize { + continue + } + if event.Key == termbox.KeyEnter || event.Key == termbox.KeyEsc || event.Key == termbox.KeyCtrlC { + return nil + } + } +} + +func (ui *TerminalUI) confirm(question string) (bool, error) { + selectedYes := true + for { + width, height := begin_screen("Use the arrow keys or Tab to choose. Press Enter to confirm or Escape for no.") + for index, line := range wrap_lines(question, width) { + draw_text(0, index+2, line, termbox.ColorDefault) + } + optionY := height - 3 + if optionY < 0 { + optionY = 0 + } + noY := optionY + 1 + if noY >= height { + noY = height - 1 + } + draw_text(0, optionY, choice_label("Yes", selectedYes), termbox.ColorDefault) + draw_text(0, noY, choice_label("No", !selectedYes), termbox.ColorDefault) + cursorY := noY + if selectedYes { + cursorY = optionY + } + set_cursor(0, cursorY, width, height) + if err := termbox.Flush(); err != nil { + return false, err + } + event, err := ui.poll_key() + if err != nil { + return false, err + } + if event.Type == termbox.EventResize { + continue + } + switch event.Key { + case termbox.KeyArrowLeft, termbox.KeyArrowRight, termbox.KeyArrowUp, termbox.KeyArrowDown, termbox.KeyTab: + selectedYes = !selectedYes + case termbox.KeyEnter: + return selectedYes, nil + case termbox.KeyEsc, termbox.KeyCtrlC: + return false, nil + } + } +} + +func visible_input(value []rune, position int, password bool, width int) ([]rune, int) { + display := append([]rune(nil), value...) + if password { + for index := range display { + display[index] = '*' + } + } + if width < 1 { + return nil, 0 + } + start := position + usedColumns := 0 + for start > 0 { + characterWidth := runewidth.RuneWidth(display[start-1]) + if characterWidth < 1 { + characterWidth = 1 + } + if usedColumns+characterWidth >= width { + break + } + usedColumns += characterWidth + start-- + } + end := start + visibleColumns := 0 + for end < len(display) { + characterWidth := runewidth.RuneWidth(display[end]) + if characterWidth < 1 { + characterWidth = 1 + } + if visibleColumns+characterWidth > width { + break + } + visibleColumns += characterWidth + end++ + } + return display[start:end], runewidth.StringWidth(string(display[start:position])) +} + +func (ui *TerminalUI) input(instructions, initial string, password bool) (string, bool, error) { + value := []rune(initial) + position := len(value) + for { + width, height := begin_screen("Type text and press Enter. Press Escape to cancel.") + for index, line := range wrap_lines(instructions, width) { + draw_text(0, index+2, line, termbox.ColorDefault) + } + inputY := height - 2 + if inputY < 0 { + inputY = 0 + } + display, cursorX := visible_input(value, position, password, width) + draw_text(0, inputY, string(display), termbox.ColorDefault) + set_cursor(cursorX, inputY, width, height) + if err := termbox.Flush(); err != nil { + return "", false, err + } + event, err := ui.poll_key() + if err != nil { + return "", false, err + } + if event.Type == termbox.EventResize { + continue + } + if event.Ch != 0 { + value = append(value, 0) + copy(value[position+1:], value[position:]) + value[position] = event.Ch + position++ + continue + } + switch event.Key { + case termbox.KeyEnter: + return string(value), false, nil + case termbox.KeyEsc, termbox.KeyCtrlC: + return "", true, nil + case termbox.KeyArrowLeft: + if position > 0 { + position-- + } + case termbox.KeyArrowRight: + if position < len(value) { + position++ + } + case termbox.KeyHome: + position = 0 + case termbox.KeyEnd: + position = len(value) + case termbox.KeyBackspace, termbox.KeyBackspace2: + if position > 0 { + value = append(value[:position-1], value[position:]...) + position-- + } + case termbox.KeyDelete: + if position < len(value) { + value = append(value[:position], value[position+1:]...) + } + } + } +} + +func (ui *TerminalUI) menu(options []string) (int, bool, error) { + if len(options) == 0 { + return 0, true, nil + } + selected := 0 + for { + width, height := begin_screen("Use Up and Down arrows, then press Enter. Press Escape to go back.") + draw_text(0, 2, "Please select one", termbox.ColorDefault|termbox.AttrBold) + firstRow := 4 + if firstRow >= height { + firstRow = height - 1 + if firstRow < 0 { + firstRow = 0 + } + } + availableRows := height - firstRow + if availableRows < 1 { + availableRows = 1 + } + start := 0 + if selected >= availableRows { + start = selected - availableRows + 1 + } + end := start + availableRows + if end > len(options) { + end = len(options) + } + for index := start; index < end; index++ { + draw_text(0, firstRow+index-start, choice_label(options[index], index == selected), termbox.ColorDefault) + } + set_cursor(0, firstRow+selected-start, width, height) + if err := termbox.Flush(); err != nil { + return 0, false, err + } + event, err := ui.poll_key() + if err != nil { + return 0, false, err + } + if event.Type == termbox.EventResize { + continue + } + if event.Ch != 0 { + wanted := unicode.ToLower(event.Ch) + for offset := 1; offset <= len(options); offset++ { + candidate := (selected + offset) % len(options) + label := []rune(options[candidate]) + if len(label) > 0 && unicode.ToLower(label[0]) == wanted { + selected = candidate + break + } + } + continue + } + switch event.Key { + case termbox.KeyArrowUp: + if selected > 0 { + selected-- + } + case termbox.KeyArrowDown, termbox.KeyTab: + if selected < len(options)-1 { + selected++ + } + case termbox.KeyHome: + selected = 0 + case termbox.KeyEnd: + selected = len(options) - 1 + case termbox.KeyPgup: + selected -= availableRows + if selected < 0 { + selected = 0 + } + case termbox.KeyPgdn: + selected += availableRows + if selected >= len(options) { + selected = len(options) - 1 + } + case termbox.KeyEnter: + return selected, false, nil + case termbox.KeyEsc, termbox.KeyCtrlC: + return 0, true, nil + } + } +} diff --git a/cmd/barnard-ui/terminal_ui_test.go b/cmd/barnard-ui/terminal_ui_test.go new file mode 100644 index 0000000..8e63b68 --- /dev/null +++ b/cmd/barnard-ui/terminal_ui_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestWrapLinesPreservesParagraphsAndBoundsWidth(t *testing.T) { + got := wrap_lines("one two three\n\nfour", 7) + want := []string{"one two", "three", "", "four"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("wrap_lines = %#v; want %#v", got, want) + } +} + +func TestWrapLinesMakesProgressWhenRuneIsWiderThanScreen(t *testing.T) { + got := wrap_lines("界a", 1) + want := []string{"界", "a"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("wrap_lines = %#v; want %#v", got, want) + } +} + +func TestVisibleInputMasksPasswords(t *testing.T) { + got, cursor := visible_input([]rune("secret"), 6, true, 20) + if string(got) != "******" || cursor != 6 { + t.Fatalf("visible_input = %q at %d; want masked text at 6", string(got), cursor) + } +} + +func TestVisibleInputKeepsEditingCursorOnScreen(t *testing.T) { + got, cursor := visible_input([]rune("abcdefghij"), 5, false, 4) + if string(got) != "cdef" || cursor != 3 { + t.Fatalf("visible_input = %q at %d; want cdef at 3", string(got), cursor) + } +} + +func TestVisibleInputUsesTerminalColumnWidths(t *testing.T) { + got, cursor := visible_input([]rune("a界b"), 2, false, 3) + if string(got) != "界b" || cursor != 2 { + t.Fatalf("visible_input = %q at %d; want 界b at 2", string(got), cursor) + } +} + +func TestChoiceLabelDoesNotChangeWithSelection(t *testing.T) { + selected := choice_label("Connect", true) + unselected := choice_label("Connect", false) + if selected != "Connect" || unselected != "Connect" { + t.Fatalf("choice labels = %q and %q; selection must be represented only by the cursor", selected, unselected) + } +} diff --git a/main.go b/main.go index 62f8829..1f608e6 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "crypto/tls" "flag" "fmt" + "io" "log" "net" "net/http" @@ -74,6 +75,7 @@ func main() { server := flag.String("server", "localhost:64738", "the server to connect to") username := flag.String("username", "", "the username of the client") password := flag.String("password", "", "the password of the server") + passwordFile := flag.String("password-file", "", "read the server password from a file") insecure := flag.Bool("insecure", false, "skip server certificate verification") certificate := flag.String("certificate", "", "PEM encoded certificate and private key") cfgfn := flag.String("config", "~/.barnard.toml", "Path to TOML formatted configuration file") @@ -229,7 +231,11 @@ func main() { b.NoiseSuppressor.SetEnabled(enabled) b.Config.Username = *username - b.Config.Password = *password + resolvedPassword, err := resolve_password(*password, *passwordFile) + if err != nil { + handle_raw_error(err) + } + b.Config.Password = resolvedPassword if *insecure { b.TLSConfig.InsecureSkipVerify = true @@ -255,6 +261,40 @@ func main() { handle_error(&b) } +func resolve_password(password, passwordFile string) (string, error) { + if passwordFile == "" { + return password, nil + } + if password != "" { + return "", fmt.Errorf("password and password-file cannot be used together") + } + file, err := os.Open(passwordFile) + if err != nil { + return "", fmt.Errorf("read password file: %w", err) + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return "", fmt.Errorf("inspect password file: %w", err) + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("password file must be a regular file") + } + const maximumPasswordFileSize = 4096 + contents, err := io.ReadAll(io.LimitReader(file, maximumPasswordFileSize+1)) + if err != nil { + return "", fmt.Errorf("read password file: %w", err) + } + if len(contents) > maximumPasswordFileSize { + return "", fmt.Errorf("password file is too large") + } + contents = []byte(strings.TrimSuffix(strings.TrimSuffix(string(contents), "\n"), "\r")) + if strings.ContainsAny(string(contents), "\r\n") { + return "", fmt.Errorf("password file must contain exactly one line") + } + return string(contents), nil +} + // audioIntervalDuration converts the packet duration requested at startup to // one of the Opus durations supported by Mumble. func audioIntervalDuration(milliseconds int) (time.Duration, error) { diff --git a/password_file_test.go b/password_file_test.go new file mode 100644 index 0000000..3fcee7d --- /dev/null +++ b/password_file_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolvePasswordUsesDirectValue(t *testing.T) { + password, err := resolve_password(" secret ", "") + if err != nil { + t.Fatal(err) + } + if password != " secret " { + t.Fatalf("password = %q; want whitespace preserved", password) + } +} + +func TestResolvePasswordReadsProtectedFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "password") + if err := os.WriteFile(path, []byte(" secret value \n"), 0600); err != nil { + t.Fatal(err) + } + password, err := resolve_password("", path) + if err != nil { + t.Fatal(err) + } + if password != " secret value " { + t.Fatalf("password = %q; want surrounding spaces preserved", password) + } +} + +func TestResolvePasswordRejectsAmbiguousSources(t *testing.T) { + path := filepath.Join(t.TempDir(), "password") + if err := os.WriteFile(path, []byte("file secret"), 0600); err != nil { + t.Fatal(err) + } + _, err := resolve_password("argument secret", path) + if err == nil || !strings.Contains(err.Error(), "cannot be used together") { + t.Fatalf("resolve_password error = %v", err) + } +} + +func TestResolvePasswordRejectsMultipleLines(t *testing.T) { + path := filepath.Join(t.TempDir(), "password") + if err := os.WriteFile(path, []byte("first\nsecond\n"), 0600); err != nil { + t.Fatal(err) + } + _, err := resolve_password("", path) + if err == nil || !strings.Contains(err.Error(), "exactly one line") { + t.Fatalf("resolve_password error = %v", err) + } +} + +func TestResolvePasswordRejectsOversizedFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "password") + if err := os.WriteFile(path, []byte(strings.Repeat("x", 4097)), 0600); err != nil { + t.Fatal(err) + } + _, err := resolve_password("", path) + if err == nil || !strings.Contains(err.Error(), "too large") { + t.Fatalf("resolve_password error = %v", err) + } +}