Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions apps/dust_api/lib/dust_api/handlers/network_handler.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
defmodule Dust.Api.Handlers.NetworkHandler do
@moduledoc """
Network startup endpoints.

`POST /api/v1/network/start` lazily spawns the Go `tsnet_sidecar`.

The bridge boots in **deferred mode** when no keystore exists on disk
so that Tailscale doesn't register the node under the placeholder
`node_name` (`"dust"`). `dustctl init` and the Web UI's `SetupLive`
call this endpoint AFTER the user has picked their device name, so
the sidecar's Tailscale identity is correct on its first connection.

No-op (returns 200) when the sidecar is already running.
"""

import Plug.Conn

@doc "Start the Tailscale sidecar (or no-op if already running)."
@spec start(Plug.Conn.t()) :: Plug.Conn.t()
def start(conn) do
case Dust.Bridge.start_sidecar() do
:ok ->
json_response(conn, 200, %{status: "started"})

{:error, reason} ->
json_response(conn, 500, %{error: inspect(reason)})
end
end

defp json_response(conn, status, body) do
conn
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(body))
end
end
2 changes: 2 additions & 0 deletions apps/dust_api/lib/dust_api/handlers/status_handler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ defmodule Dust.Api.Handlers.StatusHandler do
disk: disk_status(),
network: network,
persist_dir: Dust.Utilities.Config.persist_dir(),
ui_port: Dust.Utilities.Config.ui_port(),
ui_bind: Dust.Utilities.Config.ui_bind(),
uptime_ms: :erlang.statistics(:wall_clock) |> elem(0),
version: "0.1.5"
}
Expand Down
6 changes: 6 additions & 0 deletions apps/dust_api/lib/dust_api/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ defmodule Dust.Api.Router do
Dust.Api.Handlers.ServiceHandler.stop(conn)
end

# ── Network ────────────────────────────────────────────────────────────

post "/api/v1/network/start" do
Dust.Api.Handlers.NetworkHandler.start(conn)
end

# ── Garbage Collection ─────────────────────────────────────────────────

get "/api/v1/gc/stats" do
Expand Down
120 changes: 91 additions & 29 deletions apps/dust_bridge/lib/dust_bridge.ex
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,30 @@ defmodule Dust.Bridge do
GenServer.call(__MODULE__, {:send_command, command}, timeout)
end

@doc """
Lazily spawns the Go `tsnet_sidecar` if the bridge was started in
deferred mode.

Deferred mode is used during first-time setup: the bridge boots
without touching Tailscale so it doesn't register a hostname using
the placeholder `node_name` (`"dust"`). The init flow (CLI `dustctl
init` or the Web UI `SetupLive`) calls this after the user has chosen
their device name so the sidecar's Tailscale identity is correct on
its very first connection.

Returns `:ok` if the sidecar is now running (or was already running).
"""
@spec start_sidecar() :: :ok | {:error, term()}
def start_sidecar do
GenServer.call(__MODULE__, :start_sidecar)
end

@doc "True if the sidecar Port is open and accepting commands."
@spec sidecar_running?() :: boolean()
def sidecar_running? do
GenServer.call(__MODULE__, :sidecar_running?)
end

@doc """
Request the master key and OTP cookie from a peer node over Tailscale using a token.

Expand Down Expand Up @@ -227,41 +251,43 @@ defmodule Dust.Bridge do
# ── GenServer callbacks ─────────────────────────────────────────────────

@impl true
@spec init(keyword()) :: {:ok, %{port: port()}}
@spec init(keyword()) :: {:ok, map()}
def init(opts) do
sidecar = Keyword.get(opts, :sidecar_path, sidecar_path())
state_dir = Keyword.get(opts, :ts_state_dir, Dust.Utilities.File.ts_state_dir())
state = %{port: nil, opts: opts}

if Keyword.get(opts, :defer, first_time_setup?()) do
# No keystore yet: defer sidecar startup so Tailscale doesn't get
# registered under the placeholder node_name ("dust"). The init
# flow will call `start_sidecar/0` after the user picks a name.
Logger.info("Bridge: deferred sidecar startup — waiting for first-time init to complete")
{:ok, state}
else
{:ok, open_sidecar_port(state)}
end
end

# The Tailscale hostname encodes the node's chosen name so peers can
# discover each other by name. Read directly from Config rather than
# parsing Node.self(), since the node atom is "dust@<name>" and we
# want the host portion, not the constant "dust" prefix.
hostname =
System.get_env("TS_HOSTNAME") || "dust-node-#{Dust.Utilities.Config.node_name()}"
@impl true
def handle_call(:start_sidecar, _from, %{port: nil} = state) do
Logger.info("Bridge: opening sidecar port after first-time init")
new_state = open_sidecar_port(state)

ts_tags =
Keyword.get(
opts,
:ts_tags,
Application.get_env(:dust_bridge, :ts_tags, "tag:dust-node")
)
# Re-trigger the one-shot port-exposure setup now that the sidecar
# is up; the Bridge.Setup task may have already run and failed.
_ = Task.start(fn -> Process.sleep(1_000); Dust.Bridge.Setup.run() end)

port =
Port.open({:spawn_executable, sidecar}, [
:binary,
:exit_status,
{:packet, 4},
env: [
{~c"TS_HOSTNAME", to_charlist(hostname)},
{~c"TS_STATE_DIR", to_charlist(state_dir)},
{~c"TS_TAGS", to_charlist(ts_tags)}
]
])
{:reply, :ok, new_state}
end

{:ok, %{port: port}}
def handle_call(:start_sidecar, _from, state),
do: {:reply, :ok, state}

def handle_call(:sidecar_running?, _from, %{port: port} = state),
do: {:reply, is_port(port), state}

def handle_call({:send_command, _command}, _from, %{port: nil} = state) do
{:reply, {:error, :sidecar_deferred}, state}
end

@impl true
def handle_call({:send_command, command}, _from, %{port: port} = state) do
Port.command(port, command)

Expand All @@ -278,7 +304,7 @@ defmodule Dust.Bridge do
end

@impl true
def handle_info({port, {:exit_status, code}}, %{port: port} = state) do
def handle_info({port, {:exit_status, code}}, %{port: port} = state) when is_port(port) do
Logger.error("Bridge: Go sidecar exited with code #{code}")
{:stop, {:sidecar_exited, code}, state}
end
Expand All @@ -287,6 +313,42 @@ defmodule Dust.Bridge do

# ── Private ─────────────────────────────────────────────────────────────

defp open_sidecar_port(state) do
opts = Map.get(state, :opts, [])
sidecar = Keyword.get(opts, :sidecar_path, sidecar_path())
state_dir = Keyword.get(opts, :ts_state_dir, Dust.Utilities.File.ts_state_dir())

# Read node_name at port-open time — for deferred starts this happens
# after the user has picked their device name during init.
hostname =
System.get_env("TS_HOSTNAME") || "dust-node-#{Dust.Utilities.Config.node_name()}"

ts_tags =
Keyword.get(
opts,
:ts_tags,
Application.get_env(:dust_bridge, :ts_tags, "tag:dust-node")
)

port =
Port.open({:spawn_executable, sidecar}, [
:binary,
:exit_status,
{:packet, 4},
env: [
{~c"TS_HOSTNAME", to_charlist(hostname)},
{~c"TS_STATE_DIR", to_charlist(state_dir)},
{~c"TS_TAGS", to_charlist(ts_tags)}
]
])

%{state | port: port}
end

defp first_time_setup? do
not File.exists?(Dust.Utilities.File.master_key_file())
end

@spec sidecar_path() :: Path.t()
defp sidecar_path do
binary_name =
Expand Down
8 changes: 6 additions & 2 deletions apps/dust_bridge/test/bridge_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,13 @@ defmodule Dust.BridgeTest do

File.chmod!(wrapper, 0o755)

# Start the Bridge GenServer with the fake sidecar
# Start the Bridge GenServer with the fake sidecar. `defer: false`
# bypasses the first-time-setup check that would normally hold the
# Port closed until `Dust.Bridge.start_sidecar/0` is called.
pid =
start_supervised!({Dust.Bridge, [sidecar_path: wrapper, ts_state_dir: System.tmp_dir!()]})
start_supervised!(
{Dust.Bridge, [sidecar_path: wrapper, ts_state_dir: System.tmp_dir!(), defer: false]}
)

# Give the script a moment to boot
Process.sleep(200)
Expand Down
11 changes: 10 additions & 1 deletion apps/dust_cli/lib/dust_cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ defmodule Dust.CLI do
gc stats Show garbage collection statistics
gc sweep Trigger a manual GC sweep

ui open Open the web UI in your browser
ui status Show the web UI URL and reachability

help Show this help message
version Show version

Expand All @@ -62,7 +65,7 @@ defmodule Dust.CLI do
@version "0.1.5"

# Commands that DO NOT require Tailscale connectivity
@no_network_required ~w(init status auth daemon unlock lock config help version)
@no_network_required ~w(init status auth daemon unlock lock config help version ui)

@doc false
def run(args) do
Expand Down Expand Up @@ -197,6 +200,8 @@ defmodule Dust.CLI do

defp dispatch({config, ["gc" | args]}), do: Commands.Gc.run(config, args)

defp dispatch({config, ["ui" | args]}), do: Commands.Ui.run(config, args)

defp dispatch({_config, ["version" | _]}) do
IO.puts("dustctl #{@version}")
0
Expand Down Expand Up @@ -255,6 +260,10 @@ defmodule Dust.CLI do
{"gc stats", "Show garbage collection statistics"},
{"gc sweep", "Trigger a manual GC sweep"}
]},
{"Web UI", [
{"ui open", "Open the web UI in your browser"},
{"ui status", "Show the web UI URL and reachability"}
]},
{"Other", [
{"help", "Show this help message"},
{"version", "Show version"}
Expand Down
92 changes: 88 additions & 4 deletions apps/dust_cli/lib/dust_cli/commands/init.ex
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,14 @@ defmodule Dust.CLI.Commands.Init do

IO.puts("")

# Step 3: Unlock key store
# Step 3: Node name (must happen BEFORE Tailscale starts — changing
# the name later forces a re-auth because Tailscale identity is keyed
# by hostname).
setup_node_name(config)

IO.puts("")

# Step 4: Unlock key store
IO.puts(" Checking key store...")

case Client.get(config, "/api/v1/status") do
Expand Down Expand Up @@ -105,12 +112,12 @@ defmodule Dust.CLI.Commands.Init do

IO.puts("")

# Step 4: Node name
setup_node_name(config)
# Step 5: Bring up Tailscale now that node_name is finalized.
start_network(config)

IO.puts("")

# Step 5: Network setup
# Step 6: Network setup
Formatter.heading("Network Setup")
IO.puts("")

Expand Down Expand Up @@ -202,6 +209,83 @@ defmodule Dust.CLI.Commands.Init do

defp valid_node_name?(_), do: false

# ── Bring up Tailscale ────────────────────────────────────────────────

defp start_network(config) do
IO.puts(" Starting Tailscale…")

case Client.post(config, "/api/v1/network/start", %{}) do
{200, _} ->
Formatter.success("Tailscale sidecar started")
IO.puts("")
wait_for_tailscale(config)

other ->
Formatter.warning("Could not start Tailscale sidecar: #{inspect(other)}")
Formatter.info("You may need to run 'dustctl init' again after fixing the issue.")
end
end

# Cold-starting the Go sidecar (load tsnet state, register with control
# plane, mint a login URL) routinely takes 15–45 s. Poll long enough to
# surface either the auth URL or the connected state inline so the user
# doesn't have to follow up with `dustctl auth`.
@tailscale_poll_total_s 45
@tailscale_poll_interval_ms 2_000

defp wait_for_tailscale(config) do
Owl.Spinner.start(id: :ts_init, labels: [processing: "Reaching Tailscale…"])

case poll_tailscale(config, @tailscale_poll_total_s) do
{:authenticated, self_ip} ->
spinner_stop(id: :ts_init, resolution: :ok, label: "Tailscale connected (#{self_ip})")
:ok

{:auth_url, url} ->
spinner_stop(id: :ts_init, resolution: :ok, label: "Tailscale auth URL is ready")
IO.puts("")

Formatter.info_box("Tailscale Auth", [
"Open this URL on any device to authenticate this node:\n\n",
Owl.Data.tag(" " <> url, [:cyan, :underline])
])

IO.puts("")
Formatter.info("Run 'dustctl auth' to wait for authentication.")
:ok

:still_starting ->
spinner_stop(id: :ts_init, resolution: :error, label: "Tailscale did not respond in time")
Formatter.info("Run 'dustctl auth' shortly to retrieve the login URL.")
:ok
end
end

defp poll_tailscale(_config, remaining_s) when remaining_s <= 0, do: :still_starting

defp poll_tailscale(config, remaining_s) do
:timer.sleep(@tailscale_poll_interval_ms)

case Client.get(config, "/api/v1/status") do
{200, {:ok, %{"network" => %{"connected" => true, "self_ip" => ip}}}}
when is_binary(ip) and ip != "" ->
{:authenticated, ip}

{200, {:ok, %{"network" => %{"auth_url" => url}}}}
when is_binary(url) and url != "" ->
{:auth_url, url}

_ ->
poll_tailscale(config, remaining_s - div(@tailscale_poll_interval_ms, 1_000))
end
end

defp spinner_stop(opts) do
Owl.Spinner.stop(opts)
rescue
_ -> :ok
end

# ── New network ────────────────────────────────────────────────────────

defp setup_new_network(config) do
Expand Down
5 changes: 4 additions & 1 deletion apps/dust_cli/lib/dust_cli/commands/network.ex
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ defmodule Dust.CLI.Commands.Network do
auth_url
else
Owl.Spinner.start(id: :auth_poll, labels: [processing: "Checking for login URL..."])
url = poll_for_auth_url(config, 15)
# Cold sidecar starts can take 30–45s before tsnet hands out a
# login URL; poll long enough to ride through that without
# falsely telling the user nothing is happening.
url = poll_for_auth_url(config, 60)
spinner_stop(id: :auth_poll, resolution: :ok)
url
end
Expand Down
Loading
Loading