Replace Python launcher with native Go UI
This commit is contained in:
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user