feat: desktop connect-to-server over SSH tunnel (issue #26, phase 5)
This commit is contained in:
@@ -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
|
||||
|
||||
52
lib/bds/desktop/dialogs.ex
Normal file
52
lib/bds/desktop/dialogs.ex
Normal 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
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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")
|
||||
|
||||
180
lib/bds/desktop/remote_connection.ex
Normal file
180
lib/bds/desktop/remote_connection.ex
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user