defmodule BDS.Server do @moduledoc """ Headless server mode (issue #26). Boots the app without any wx window and serves clients over a single SSH daemon: TUI sessions run server-side via `ExRatatui.SSH` (only terminal cells cross the wire), and GUI clients tunnel to the loopback HTTP endpoint through the same daemon (`tcpip_tunnel_out`). Authentication is public-key only; host key and `authorized_keys` live in the private app data dir next to the database — never in the repo or a priv dir. """ @doc """ Resolves the boot mode from the `BDS_MODE` environment variable: `"server"` (headless + SSH daemon), `"tui"` (headless + SSH daemon + a TUI attached to the launching terminal), anything else is desktop. """ @spec mode(String.t() | nil) :: :desktop | :server | :tui def mode(value \\ System.get_env("BDS_MODE")) def mode(value) when is_binary(value) do case String.downcase(value) do "server" -> :server "tui" -> :tui _other -> :desktop end end 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: `/ssh` next to the database." @spec ssh_dir() :: Path.t() def ssh_dir do Application.get_env(:bds, BDS.Repo)[:database] |> Path.dirname() |> Path.join("ssh") end @doc """ Ensures the SSH directory exists with an `authorized_keys` file (created empty and chmod 600 on first boot; existing keys are never touched). """ @spec ensure_ssh_dir!(Path.t()) :: Path.t() def ensure_ssh_dir!(dir) do File.mkdir_p!(dir) keys_path = Path.join(dir, "authorized_keys") unless File.exists?(keys_path) do File.write!(keys_path, "") File.chmod!(keys_path, 0o600) end dir end @doc """ Options for `ExRatatui.SSH.Daemon`: serves `BDS.TUI`, public-key auth only, and allows TCP/IP tunneling out so GUI clients can reach the loopback HTTP endpoint through the SSH connection. """ @spec daemon_opts(Path.t()) :: keyword() def daemon_opts(dir \\ ssh_dir()) do dir = ensure_ssh_dir!(dir) system_dir = ExRatatui.SSH.Daemon.ensure_host_key!(dir) [ mod: BDS.TUI, port: ssh_port(), system_dir: system_dir, user_dir: String.to_charlist(dir), auth_methods: ~c"publickey", tcpip_tunnel_out: true ] end @doc "SSH listen port: `BDS_SSH_PORT` env or `:bds, :server` config." @spec ssh_port() :: pos_integer() def ssh_port do case System.get_env("BDS_SSH_PORT") do value when is_binary(value) and value != "" -> String.to_integer(value) _other -> Application.get_env(:bds, :server)[:ssh_port] || 2222 end end @doc false # Child spec that defers all filesystem work (key generation) to process # start, so building a children list never touches the data dir. def child_spec(_opts) do %{ id: __MODULE__, start: {__MODULE__, :start_link, []}, type: :worker } end @doc false def start_link do ExRatatui.SSH.Daemon.start_link(daemon_opts()) end end