216 lines
7.6 KiB
Elixir
216 lines
7.6 KiB
Elixir
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 """
|
|
The mode the app actually boots in: the resolved `BDS_MODE`, except that
|
|
desktop mode falls back to the TUI when no graphical session is available
|
|
(issue #33). Starting the app anywhere then still opens a UI — graphical
|
|
when possible, the terminal one otherwise.
|
|
|
|
Per platform: non-Darwin unix needs `DISPLAY` or `WAYLAND_DISPLAY` to run
|
|
wx — that check also covers plain ssh sessions, while `ssh -X` with a
|
|
forwarded display keeps the GUI. macOS never sets `DISPLAY`, so there an
|
|
ssh session (`SSH_CONNECTION`/`SSH_CLIENT`/`SSH_TTY`) is the headless
|
|
signal and a local launch always has a graphical session. Windows always
|
|
boots the GUI.
|
|
"""
|
|
@spec effective_mode(:desktop | :server | :tui, {atom(), atom()}, %{
|
|
optional(String.t()) => String.t()
|
|
}) :: :desktop | :server | :tui
|
|
def effective_mode(mode \\ mode(), os_type \\ :os.type(), env \\ System.get_env())
|
|
|
|
def effective_mode(:desktop, {:unix, :darwin}, env) do
|
|
if env_any?(env, ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]), do: :tui, else: :desktop
|
|
end
|
|
|
|
def effective_mode(:desktop, {:unix, _os}, env) do
|
|
if env_any?(env, ["DISPLAY", "WAYLAND_DISPLAY"]), do: :desktop, else: :tui
|
|
end
|
|
|
|
def effective_mode(mode, _os_type, _env), do: mode
|
|
|
|
defp env_any?(env, vars), do: Enum.any?(vars, &(env[&1] not in [nil, ""]))
|
|
|
|
@doc """
|
|
Prepares the OS environment for the given effective boot mode and returns
|
|
it. The `:desktop` dependency application boots wx inside its own
|
|
`Application.start/2` — before any BDS supervisor runs — and that wx load
|
|
crashes the whole VM on a display-less system. For every non-desktop mode
|
|
this sets `NO_WX=1`, elixir-desktop's own switch that turns all of its wx
|
|
calls into no-ops, so the dependency starts inert and the TUI fallback
|
|
(issue #33) can actually boot.
|
|
|
|
In tui mode the app additionally owns the launching terminal, so every
|
|
other terminal writer is silenced: the default (console) logger handler
|
|
is replaced with a rotating file handler at `tui_log_file/0`, and
|
|
Bumblebee's model-download progress bar (raw stdout writes) is disabled.
|
|
Each stray line would otherwise scroll the TUI's alternate screen up one
|
|
row and corrupt the rendering. Desktop and server modes keep logging to
|
|
the terminal, and SSH-served TUI sessions are unaffected either way —
|
|
their cells travel over the SSH channel, away from the server console.
|
|
|
|
Called from `config/runtime.exs`: runtime config is the only hook that
|
|
runs before dependency applications start, in both `mix run` and releases.
|
|
"""
|
|
@spec prepare_boot_env(:desktop | :server | :tui, (-> :ok)) :: :desktop | :server | :tui
|
|
def prepare_boot_env(mode \\ effective_mode(), redirect_logs \\ &redirect_logging_to_file/0) do
|
|
if mode != :desktop, do: System.put_env("NO_WX", "1")
|
|
|
|
if mode == :tui do
|
|
Application.put_env(:bumblebee, :progress_bar_enabled, false)
|
|
redirect_logs.()
|
|
end
|
|
|
|
mode
|
|
end
|
|
|
|
@doc """
|
|
Log file that replaces console logging in local tui mode: a
|
|
machine-specific, regenerable artifact, so it lives in the private app
|
|
dir (PrivateArtifactsLiveInOsAppDir). Overridable via `:bds, :tui_log_file`.
|
|
"""
|
|
@spec tui_log_file() :: Path.t()
|
|
def tui_log_file do
|
|
Application.get_env(:bds, :tui_log_file) ||
|
|
Path.join([BDS.Projects.private_dir(), "logs", "bds.log"])
|
|
end
|
|
|
|
@doc """
|
|
Replaces the default (console) logger handler with a rotating file
|
|
handler writing to `tui_log_file/0`, keeping the configured level,
|
|
filters, and Elixir formatter. See `prepare_boot_env/2`.
|
|
"""
|
|
@spec redirect_logging_to_file() :: :ok
|
|
def redirect_logging_to_file do
|
|
log_file = tui_log_file()
|
|
File.mkdir_p!(Path.dirname(log_file))
|
|
|
|
with {:ok, handler} <- :logger.get_handler_config(:default) do
|
|
:ok = :logger.remove_handler(:default)
|
|
|
|
:ok =
|
|
:logger.add_handler(
|
|
:default,
|
|
:logger_std_h,
|
|
handler
|
|
|> Map.take([:level, :filters, :filter_default, :formatter])
|
|
|> Map.put(:config, %{
|
|
file: String.to_charlist(log_file),
|
|
max_no_bytes: 10_000_000,
|
|
max_no_files: 5
|
|
})
|
|
)
|
|
end
|
|
|
|
:ok
|
|
end
|
|
|
|
@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 \\ effective_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
|
|
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
|