feat: desktop connect-to-server over SSH tunnel (issue #26, phase 5)

This commit is contained in:
2026-07-15 18:39:04 +02:00
parent a3ca44640c
commit df2bdc2041
18 changed files with 1094 additions and 479 deletions

View File

@@ -90,7 +90,8 @@ defmodule BDS.Application do
[
{Desktop.Window, window_opts},
Supervisor.child_spec({BDS.Desktop.MainWindow, []}, id: BDS.Desktop.MainWindow.Watcher),
{BDS.Desktop.DeepLink, []}
{BDS.Desktop.DeepLink, []},
{BDS.Desktop.RemoteConnection, []}
]
end
end

View File

@@ -0,0 +1,52 @@
defmodule BDS.Desktop.Dialogs do
@moduledoc false
# Native single-line text prompt, same osascript approach as
# BDS.Desktop.FilePicker. Returns {:ok, text} | :cancel | {:error, map}.
def prompt_text(message, default \\ "") when is_binary(message) do
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" do
:cancel
else
case :os.type() do
{:unix, :darwin} -> prompt_text_macos(message, default)
_other -> {:error, %{message: "Dialogs are only supported on macOS desktop"}}
end
end
end
def alert(title, message) when is_binary(title) and is_binary(message) do
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" or :os.type() != {:unix, :darwin} do
:ok
else
script = "display alert \"#{escape(title)}\" message \"#{escape(message)}\""
_ = System.cmd("osascript", ["-e", script], stderr_to_stdout: true)
:ok
end
end
defp prompt_text_macos(message, default) do
script =
"text returned of (display dialog \"#{escape(message)}\" default answer \"#{escape(default)}\")"
case System.cmd("osascript", ["-e", script], stderr_to_stdout: true) do
{output, 0} -> {:ok, String.trim(output)}
{output, _status} -> normalize_failure(output)
end
end
defp normalize_failure(output) do
message = String.trim(output)
if message == "" or String.contains?(String.downcase(message), "canceled") do
:cancel
else
{:error, %{message: message}}
end
end
defp escape(value) do
value
|> String.replace("\\", "\\\\")
|> String.replace("\"", "\\\"")
end
end

View File

@@ -23,7 +23,8 @@ defmodule BDS.Desktop.Endpoint do
plug(BDS.Desktop.Router)
defp maybe_require_desktop_auth(conn, _opts) do
if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] do
if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] or
not BDS.Server.desktop_auth_required?() do
conn
else
Desktop.Auth.call(conn, [])

View File

@@ -67,6 +67,35 @@ defmodule BDS.Desktop.MenuBar do
{:noreply, menu}
end
def handle_event("connect_server", menu) do
prompt =
dgettext("ui", "Server address (user@host or user@host:port), public-key auth:")
with {:ok, target} <- BDS.Desktop.Dialogs.prompt_text(prompt, "user@host"),
{:ok, url} <- BDS.Desktop.RemoteConnection.connect(target) do
Window.show(BDS.Desktop.MainWindow.window_id(), url)
else
:cancel ->
:ok
{:error, reason} ->
BDS.Desktop.Dialogs.alert(
dgettext("ui", "Connect to Server"),
dgettext("ui", "Connection failed: %{reason}",
reason: BDS.Desktop.RemoteConnection.format_reason(reason)
)
)
end
{:noreply, menu}
end
def handle_event("disconnect_server", menu) do
:ok = BDS.Desktop.RemoteConnection.disconnect()
Window.show(BDS.Desktop.MainWindow.window_id(), BDS.Desktop.url())
{:noreply, menu}
end
def handle_event("open_data_folder", menu) do
_ = BDS.Desktop.ShellCommands.execute("open_data_folder")
{:noreply, menu}
@@ -186,6 +215,8 @@ defmodule BDS.Desktop.MenuBar do
defp item_label(:import_media), do: dgettext("ui", "Import Media")
defp item_label(:save), do: dgettext("ui", "Save")
defp item_label(:open_in_browser), do: dgettext("ui", "Open in Browser")
defp item_label(:connect_server), do: dgettext("ui", "Connect to Server…")
defp item_label(:disconnect_server), do: dgettext("ui", "Disconnect from Server")
defp item_label(:open_data_folder), do: dgettext("ui", "Open Data Folder")
defp item_label(:close_tab), do: dgettext("ui", "Close Tab")
defp item_label(:quit), do: dgettext("ui", "Quit")

View File

@@ -0,0 +1,180 @@
defmodule BDS.Desktop.RemoteConnection do
@moduledoc """
GUI remote mode (issue #26, phase 5).
Connects the desktop app to a headless bDS2 server over the same SSH
channel (and the same public keys) the TUI uses: `:ssh.connect` with
public-key auth, then an OTP TCP/IP tunnel to the server's loopback HTTP
endpoint. The webview then loads the local tunnel end — the server never
exposes HTTP.
Client key material lives in the same private `ssh/` directory next to
the database (`BDS.Server.ssh_dir/0`): an `id_ed25519`/`id_rsa` identity
and a `known_hosts` written on first connect.
"""
use GenServer
use Gettext, backend: BDS.Gettext
require Logger
@default_ssh_port 2222
@tunnel_timeout_ms 10_000
def start_link(opts \\ []) do
case Keyword.pop(opts, :name, __MODULE__) do
{nil, init_opts} -> GenServer.start_link(__MODULE__, init_opts)
{name, init_opts} -> GenServer.start_link(__MODULE__, init_opts, name: name)
end
end
@doc """
Parses `user@host[:port]` into connection parameters. The port is the
server's SSH port (default #{@default_ssh_port}).
"""
@spec parse_target(String.t()) ::
{:ok, %{user: String.t(), host: String.t(), port: pos_integer()}}
| {:error, :invalid_target}
def parse_target(target) when is_binary(target) do
with [user, rest] when user != "" <- String.split(String.trim(target), "@", parts: 2),
{:ok, host, port} <- parse_host_port(rest) do
{:ok, %{user: user, host: host, port: port}}
else
_other -> {:error, :invalid_target}
end
end
defp parse_host_port(rest) do
case String.split(rest, ":", parts: 2) do
[host] when host != "" ->
{:ok, host, @default_ssh_port}
[host, port] when host != "" ->
case Integer.parse(port) do
{number, ""} when number > 0 -> {:ok, host, number}
_other -> :error
end
_other ->
:error
end
end
@doc "Connects and tunnels; returns the local URL the webview should load."
@spec connect(GenServer.server(), String.t()) :: {:ok, String.t()} | {:error, term()}
def connect(server \\ __MODULE__, target) do
GenServer.call(server, {:connect, target}, @tunnel_timeout_ms + 5_000)
end
@spec disconnect(GenServer.server()) :: :ok
def disconnect(server \\ __MODULE__) do
GenServer.call(server, :disconnect)
end
@spec status(GenServer.server()) :: :disconnected | {:connected, String.t(), String.t()}
def status(server \\ __MODULE__) do
GenServer.call(server, :status)
end
@doc "Human-readable connection error for menu/shell error reporting."
@spec format_reason(term()) :: String.t()
def format_reason(:invalid_target) do
dgettext("ui", "Use the form user@host or user@host:port.")
end
def format_reason(%{message: message}) when is_binary(message), do: message
def format_reason(reason), do: inspect(reason)
## GenServer callbacks
@impl true
def init(opts) do
overrides = Keyword.get(opts, :test_overrides, [])
{:ok,
%{
conn: nil,
monitor: nil,
target: nil,
url: nil,
connect_fun: Keyword.get(overrides, :connect_fun, &:ssh.connect/3),
tunnel_fun: Keyword.get(overrides, :tunnel_fun, &:ssh.tcpip_tunnel_to_server/6),
close_fun: Keyword.get(overrides, :close_fun, &:ssh.close/1),
ssh_dir_fun: Keyword.get(overrides, :ssh_dir_fun, &BDS.Server.ssh_dir/0)
}}
end
@impl true
def handle_call({:connect, target}, _from, state) do
state = close_current(state)
with {:ok, parsed} <- parse_target(target),
{:ok, conn} <- do_connect(state, parsed),
{:ok, local_port} <- do_tunnel(state, conn) do
url = "http://127.0.0.1:#{local_port}/"
{:reply, {:ok, url},
%{
state
| conn: conn,
monitor: Process.monitor(conn),
target: String.trim(target),
url: url
}}
else
{:error, reason} = error ->
Logger.warning("Remote connection to #{inspect(target)} failed: #{inspect(reason)}")
{:reply, error, state}
end
end
def handle_call(:disconnect, _from, state) do
{:reply, :ok, close_current(state)}
end
def handle_call(:status, _from, %{conn: nil} = state) do
{:reply, :disconnected, state}
end
def handle_call(:status, _from, state) do
{:reply, {:connected, state.target, state.url}, state}
end
@impl true
def handle_info({:DOWN, monitor, :process, _pid, reason}, %{monitor: monitor} = state) do
Logger.warning("Remote SSH connection lost: #{inspect(reason)}")
{:noreply, %{state | conn: nil, monitor: nil, target: nil, url: nil}}
end
def handle_info(_message, state), do: {:noreply, state}
## Internal helpers
defp do_connect(state, %{user: user, host: host, port: port}) do
ssh_dir = BDS.Server.ensure_ssh_dir!(state.ssh_dir_fun.())
state.connect_fun.(String.to_charlist(host), port,
user: String.to_charlist(user),
user_dir: String.to_charlist(ssh_dir),
auth_methods: ~c"publickey",
# First connect records the host key in <ssh_dir>/known_hosts;
# later connects verify against it (trust-on-first-use).
silently_accept_hosts: true,
connect_timeout: @tunnel_timeout_ms
)
end
defp do_tunnel(state, conn) do
remote_port = Application.get_env(:bds, :desktop)[:port] || 4010
state.tunnel_fun.(conn, ~c"127.0.0.1", 0, ~c"127.0.0.1", remote_port, @tunnel_timeout_ms)
end
defp close_current(%{conn: nil} = state), do: state
defp close_current(state) do
if state.monitor, do: Process.demonitor(state.monitor, [:flush])
_ = state.close_fun.(state.conn)
%{state | conn: nil, monitor: nil, target: nil, url: nil}
end
end

View File

@@ -108,7 +108,9 @@ defmodule BDS.Desktop.ShellLive do
:quit,
:view_on_github,
:report_issue,
:about
:about,
:connect_server,
:disconnect_server
])
@runtime_menu_actions MapSet.new([
:undo,
@@ -1091,6 +1093,34 @@ defmodule BDS.Desktop.ShellLive do
socket
end
defp handle_socket_menu_action(socket, :connect_server) do
prompt = dgettext("ui", "Server address (user@host or user@host:port), public-key auth:")
with {:ok, target} <- BDS.Desktop.Dialogs.prompt_text(prompt, "user@host"),
{:ok, url} <- BDS.Desktop.RemoteConnection.connect(target) do
Desktop.Window.show(BDS.Desktop.MainWindow.window_id(), url)
socket
else
:cancel ->
socket
{:error, reason} ->
append_output_entry(
socket,
dgettext("ui", "Connect to Server"),
BDS.Desktop.RemoteConnection.format_reason(reason),
nil,
"error"
)
end
end
defp handle_socket_menu_action(socket, :disconnect_server) do
:ok = BDS.Desktop.RemoteConnection.disconnect()
Desktop.Window.show(BDS.Desktop.MainWindow.window_id(), BDS.Desktop.url())
socket
end
defp handle_socket_menu_action(socket, :about) do
append_output_entry(
socket,

View File

@@ -28,6 +28,17 @@ defmodule BDS.Server do
def mode(nil), do: :desktop
@doc """
Whether the HTTP endpoint requires the desktop webview auth token. Only
desktop mode does: in server/tui mode the endpoint stays loopback-only
and clients arrive through the key-authenticated SSH tunnel, which the
per-boot webview token would otherwise lock out.
"""
@spec desktop_auth_required?(:desktop | :server | :tui) :: boolean()
def desktop_auth_required?(mode \\ mode())
def desktop_auth_required?(:desktop), do: true
def desktop_auth_required?(_mode), do: false
@doc "SSH key-material directory: `<data dir>/ssh` next to the database."
@spec ssh_dir() :: Path.t()
def ssh_dir do

View File

@@ -20,6 +20,9 @@ defmodule BDS.UI.MenuBar do
%{separator: true},
%{id: :open_in_browser},
%{separator: true},
%{id: :connect_server},
%{id: :disconnect_server},
%{separator: true},
%{id: :open_data_folder},
%{separator: true},
%{id: :close_tab},