feat: headless server mode with ExRatatui SSH daemon (issue #26, phase 1)
This commit is contained in:
95
lib/bds/server.ex
Normal file
95
lib/bds/server.ex
Normal file
@@ -0,0 +1,95 @@
|
||||
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.
|
||||
Anything other than `"server"` (case-insensitive) is desktop mode.
|
||||
"""
|
||||
@spec mode(String.t() | nil) :: :desktop | :server
|
||||
def mode(value \\ System.get_env("BDS_MODE"))
|
||||
|
||||
def mode(value) when is_binary(value) do
|
||||
if String.downcase(value) == "server", do: :server, else: :desktop
|
||||
end
|
||||
|
||||
def mode(nil), do: :desktop
|
||||
|
||||
@doc "SSH key-material directory: `<data dir>/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
|
||||
Reference in New Issue
Block a user