Replace Python launcher with native Go UI

This commit is contained in:
Storm Dragon
2026-09-03 11:43:21 -04:00
parent 0d4daeb45a
commit afd4148e9a
14 changed files with 2428 additions and 961 deletions
+12 -4
View File
@@ -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
-956
View File
@@ -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()
+234
View File
@@ -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
}
}
}
+55
View File
@@ -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)
}
}
+156
View File
@@ -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}
}
}
+98
View File
@@ -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)
}
}
+275
View File
@@ -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
}
}
}
+82
View File
@@ -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)
}
}
+677
View File
@@ -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) + " = <redacted>"
}
return "<redacted>"
}
func load_servers(path string) (map[string]Server, []string, error) {
servers := make(map[string]Server)
file, err := os.Open(path)
if errors.Is(err, os.ErrNotExist) {
return servers, nil, nil
}
if err != nil {
return nil, nil, err
}
defer file.Close()
var current *Server
currentLine := 0
warnings := []string{}
finishServer := func(line int) error {
if current == nil {
return nil
}
if current.Name == "" {
return &ConfigError{Line: line, Content: "[server]", Problem: "server entry is missing a name"}
}
if current.Address == "" {
return &ConfigError{Line: line, Content: "[server]", Problem: "server entry is missing an address"}
}
if _, exists := servers[current.Name]; exists {
warnings = append(warnings, fmt.Sprintf("Duplicate server name %q near line %d; keeping the last entry.", current.Name, line))
}
servers[current.Name] = *current
return nil
}
scanner := bufio.NewScanner(file)
for lineNumber := 1; scanner.Scan(); lineNumber++ {
raw := scanner.Text()
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
if err := finishServer(currentLine); err != nil {
return nil, nil, err
}
if !strings.EqualFold(strings.TrimSpace(line[1:len(line)-1]), "server") {
return nil, nil, &ConfigError{Line: lineNumber, Content: raw, Problem: "unexpected section"}
}
current = &Server{Port: defaultPort}
currentLine = lineNumber
continue
}
if current == nil {
return nil, nil, &ConfigError{Line: lineNumber, Content: error_content(raw), Problem: "key outside of a [server] section"}
}
key, value, found := strings.Cut(raw, "=")
if !found {
return nil, nil, &ConfigError{Line: lineNumber, Content: error_content(raw), Problem: "expected key=value"}
}
key = strings.ToLower(strings.TrimSpace(key))
if key == "password" {
if strings.HasPrefix(value, " ") || strings.HasPrefix(value, "\t") {
value = value[1:]
}
} else {
value = strings.TrimSpace(value)
}
switch key {
case "name":
current.Name = value
case "address", "host":
current.Address = value
case "port":
port, valid := parse_port(value)
if !valid {
return nil, nil, &ConfigError{Line: lineNumber, Content: error_content(raw), Problem: "invalid port"}
}
current.Port = port
case "password":
current.Password = value
case "insecure":
current.Insecure = normalize_insecure(value)
default:
return nil, nil, &ConfigError{Line: lineNumber, Content: error_content(raw), Problem: fmt.Sprintf("unknown key %q", key)}
}
}
if err := scanner.Err(); err != nil {
return nil, nil, err
}
if err := finishServer(currentLine); err != nil {
return nil, nil, err
}
return servers, warnings, nil
}
func write_server_list(writer io.Writer, servers map[string]Server) error {
names := make([]string, 0, len(servers))
for name := range servers {
names = append(names, name)
}
sort.Strings(names)
if _, err := fmt.Fprintln(writer, "# barnard-ui server list"); err != nil {
return err
}
if _, err := fmt.Fprint(writer, "# Passwords are stored only when provided; this file is written with mode 0600.\n\n"); err != nil {
return err
}
for _, name := range names {
server := servers[name]
if strings.ContainsAny(server.Name+server.Address+server.Password, "\r\n") {
return fmt.Errorf("server %q contains a line break", name)
}
if _, err := fmt.Fprintf(writer, "[server]\nname = %s\naddress = %s\nport = %d\npassword = %s\ninsecure = %t\n\n",
server.Name, server.Address, server.Port, server.Password, server.Insecure); err != nil {
return err
}
}
return nil
}
func copy_file(source, destination string) error {
input, err := os.Open(source)
if err != nil {
return err
}
defer input.Close()
output, err := os.OpenFile(destination, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return err
}
if err := output.Chmod(0600); err != nil {
output.Close()
return err
}
_, copyErr := io.Copy(output, input)
closeErr := output.Close()
if copyErr != nil {
return copyErr
}
return closeErr
}
func save_servers(path string, servers map[string]Server) error {
directory := filepath.Dir(path)
if err := os.MkdirAll(directory, 0700); err != nil {
return err
}
temporary, err := os.CreateTemp(directory, filepath.Base(path)+".tmp-")
if err != nil {
return err
}
temporaryName := temporary.Name()
defer os.Remove(temporaryName)
if err := temporary.Chmod(0600); err != nil {
temporary.Close()
return err
}
if err := write_server_list(temporary, servers); err != nil {
temporary.Close()
return err
}
if err := temporary.Sync(); err != nil {
temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
if _, err := os.Stat(path); err == nil {
if err := copy_file(path, path+".bak"); err != nil {
return fmt.Errorf("create backup: %w", err)
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.Rename(temporaryName, path); err != nil {
return err
}
directoryHandle, err := os.Open(directory)
if err == nil {
_ = directoryHandle.Sync()
_ = directoryHandle.Close()
}
return nil
}
func sorted_server_names(servers map[string]Server) []string {
names := make([]string, 0, len(servers))
for name := range servers {
names = append(names, name)
}
sort.Strings(names)
return names
}
func clone_servers(servers map[string]Server) map[string]Server {
cloned := make(map[string]Server, len(servers))
for name, server := range servers {
cloned[name] = server
}
return cloned
}
func (app *App) add_server() error {
name, cancelled, err := app.ui.input("Enter a name for the new server:", "", false)
if err != nil || cancelled {
return err
}
name = strings.TrimSpace(name)
if name == "" || strings.ContainsAny(name, "\r\n") {
return app.ui.message("Server name cannot be empty or contain line breaks.")
}
rawAddress, cancelled, err := app.ui.input("Enter the server address. Add :port when it is not 64738.", "", false)
if err != nil || cancelled {
return err
}
address, port, shorthandPassword, valid := parse_server_input(rawAddress)
if !valid {
return app.ui.message("Invalid server address or port.")
}
password, cancelled, err := app.ui.input("Enter the server password, or leave it blank:", "", true)
if err != nil || cancelled {
return err
}
if strings.ContainsAny(password, "\r\n") {
return app.ui.message("Server password cannot contain line breaks.")
}
if password == "" {
password = shorthandPassword
}
insecure, err := app.ui.confirm("Skip server certificate verification for this server?")
if err != nil {
return err
}
if _, exists := app.servers[name]; exists {
overwrite, err := app.ui.confirm("A server named " + name + " already exists. Overwrite it?")
if err != nil || !overwrite {
return err
}
}
updatedServers := clone_servers(app.servers)
updatedServers[name] = Server{Name: name, Address: address, Port: port, Password: password, Insecure: insecure}
if err := save_servers(app.paths.ServerFile, updatedServers); err != nil {
return app.ui.message("Could not save server list: " + err.Error())
}
app.servers = updatedServers
app.log_line(fmt.Sprintf("Added server %s %s:%d", name, address, port))
return app.ui.message("Added server " + name)
}
func (app *App) remove_server() error {
if len(app.servers) == 0 {
return app.ui.message("No saved servers to remove.")
}
names := sorted_server_names(app.servers)
selection, cancelled, err := app.ui.menu(append(names, "Go Back"))
if err != nil || cancelled || selection == len(names) {
return err
}
name := names[selection]
confirmed, err := app.ui.confirm("Remove server " + name + "?")
if err != nil || !confirmed {
return err
}
updatedServers := clone_servers(app.servers)
delete(updatedServers, name)
if err := save_servers(app.paths.ServerFile, updatedServers); err != nil {
return app.ui.message("Could not save server list: " + err.Error())
}
app.servers = updatedServers
app.log_line("Removed server " + name)
return app.ui.message("Removed server " + name)
}
func config_has_value(path, wantedKey string) bool {
file, err := os.Open(path)
if err != nil {
return false
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, found := strings.Cut(line, "=")
if !found || !strings.EqualFold(strings.TrimSpace(key), wantedKey) {
continue
}
value = strings.TrimSpace(value)
return value != "" && value != `""` && value != "''"
}
return false
}
func format_address(address string, port int) string {
if strings.Contains(address, ":") {
return fmt.Sprintf("[%s]:%d", address, port)
}
return fmt.Sprintf("%s:%d", address, port)
}
func default_username() string {
user := os.Getenv("USER")
host, _ := os.Hostname()
switch {
case user != "" && host != "":
return user + "-" + host
case user != "":
return user
case host != "":
return host
default:
return "barnard"
}
}
func connection_args(server Server, paths Paths) ([]string, error) {
args := []string{"-server", format_address(server.Address, server.Port)}
if server.Insecure {
args = append(args, "-insecure")
}
if !config_has_value(paths.BarnardTOML, "username") {
args = append(args, "-username", default_username())
}
if _, err := os.Stat(paths.CertFile); err == nil && !config_has_value(paths.BarnardTOML, "certificate") {
args = append(args, "-certificate", paths.CertFile)
}
args = append(args, "--fifo", filepath.Join(paths.ConfigDir, "cmd"), "--buffers", "16")
return args, nil
}
func (app *App) connect() error {
if len(app.servers) == 0 {
return app.ui.message("No saved servers. Add a server first.")
}
names := sorted_server_names(app.servers)
selection, cancelled, err := app.ui.menu(append(names, "Go Back"))
if err != nil || cancelled || selection == len(names) {
return err
}
name := names[selection]
server := app.servers[name]
barnardPath, err := exec.LookPath("barnard")
if err != nil {
return app.ui.message("Required command not found: barnard")
}
args, err := connection_args(server, app.paths)
if err != nil {
return err
}
passwordFile := ""
if server.Password != "" {
passwordFile, err = write_password_file(app.paths.ConfigDir, server.Password)
if err != nil {
return app.ui.message("Could not prepare the server password: " + err.Error())
}
defer os.Remove(passwordFile)
args = append(args, "-password-file", passwordFile)
}
sessionLogFile := ""
if app.saveSessionLogs {
sessionLogFile, err = prepare_session_log(app.paths.LogDir, name)
if err != nil {
if messageErr := app.ui.message("Could not create session log: " + err.Error()); messageErr != nil {
return messageErr
}
} else {
args = append(args, "-log", "debug", "-logfile", sessionLogFile)
}
}
command := exec.Command(barnardPath, args...)
command.Stdin = os.Stdin
logHandle, err := os.OpenFile(app.paths.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return app.ui.message("Could not open launcher log: " + err.Error())
}
defer logHandle.Close()
outputWriters := []io.Writer{os.Stdout, logHandle}
if sessionLogFile != "" {
sessionOutput, err := os.OpenFile(sessionLogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return app.ui.message("Could not open session log: " + err.Error())
}
defer sessionOutput.Close()
outputWriters = append(outputWriters, sessionOutput)
}
commandOutput := io.MultiWriter(outputWriters...)
command.Stdout = commandOutput
command.Stderr = commandOutput
app.ui.close()
commandErr := run_external(app.ui, command)
var signalErr *terminalSignalError
if errors.As(commandErr, &signalErr) {
return signalErr
}
if err := app.ui.open(); err != nil {
return err
}
if receivedSignal := app.ui.take_termination(); receivedSignal != nil {
return &terminalSignalError{signal: receivedSignal}
}
if commandErr != nil {
return app.ui.message("Barnard exited with an error: " + commandErr.Error() + ". See log: " + app.paths.LogFile)
}
return nil
}
func (app *App) run() error {
for {
options := main_menu_options()
selection, cancelled, err := app.ui.menu(options)
if err != nil {
return err
}
if cancelled || selection == len(options)-1 {
return nil
}
switch options[selection] {
case "Connect":
err = app.connect()
case "Add server":
err = app.add_server()
case "Remove server":
err = app.remove_server()
case "Manage Certificate":
err = app.manage_certificate()
case "Logs":
err = app.manage_logs()
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)
}
}
+212
View File
@@ -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)
}
}
}
+470
View File
@@ -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
}
}
}
+51
View File
@@ -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)
}
}
+41 -1
View File
@@ -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) {
+65
View File
@@ -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)
}
}