Skip to content
Merged
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
9 changes: 8 additions & 1 deletion lib/universal_proxy/storage.ex
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,14 @@ defmodule UniversalProxy.Storage do
@type drive_key :: Server.drive_key()
@type state :: Server.payload()

@default_state %{drives: [], mount: nil, share: :off, share_folder: "/", capacity: nil}
@default_state %{
drives: [],
mount: nil,
share: :off,
share_folder: "/",
share_name: nil,
capacity: nil
}

# -- Read state --

Expand Down
78 changes: 72 additions & 6 deletions lib/universal_proxy/storage/server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,17 @@ defmodule UniversalProxy.Storage.Server do
mount: nil | %{device:, fs_type:, mode:, point:, stale?:, dirty?:},
share: :off | :running | :error,
share_folder: String.t(),
share_name: nil | String.t(),
capacity: nil | %{total_bytes:, used_bytes:, free_bytes:, used_pct:}
}

`share_name` is the mounted drive's derived Samba share name
(`Storage.Smbd.share_name/1` — `"usb_backup_<suffix>"`, the suffix from
the drive's USB serial or, absent one, its vendor/product id, the same
identity components that make up a drive key below). `nil` whenever
nothing is mounted. It is what `config/1`'s `[…]` section is actually
named, refreshed every convergence pass like `share_folder`.

`share_folder` is the mounted drive's stored share mapping — `"/"` for
the drive root, otherwise a drive-relative path (`"backups/ha"`). It is
`"/"` whenever nothing is mounted, and it is read from
Expand Down Expand Up @@ -475,6 +483,7 @@ defmodule UniversalProxy.Storage.Server do
mount: mount_info() | nil,
share: share(),
share_folder: String.t(),
share_name: String.t() | nil,
capacity: Mount.capacity() | nil
}

Expand Down Expand Up @@ -530,6 +539,10 @@ defmodule UniversalProxy.Storage.Server do
# Mirror of the mounted drive's stored `share_folder`, refreshed
# from `Storage.Settings` on every convergence pass.
share_folder: @root_folder,
# The mounted drive's derived Samba share name
# (`Smbd.share_name/1`), refreshed every convergence pass like
# `share_folder`. `nil` whenever nothing is mounted.
share_name: nil,
capacity: nil

# -- Client API --
Expand Down Expand Up @@ -949,6 +962,7 @@ defmodule UniversalProxy.Storage.Server do
|> reconcile_removal()
|> reconcile_mount()
|> refresh_share_folder()
|> refresh_share_name()
|> refresh_capacity()
|> reconcile_share(Keyword.get(opts, :restart_share?, true))

Expand Down Expand Up @@ -1673,6 +1687,43 @@ defmodule UniversalProxy.Storage.Server do
# the share can only ever be the drive root.
defp stored_share_folder(_state), do: @root_folder

# -- Share name --

# `active_drive_first/2` (see `refresh_drives/1`) sorts `drives` from the
# *previous* pass's mount identity — it runs before `reconcile_mount/1`,
# so a mount adopted during *this* pass (`mount_or_adopt/3` ->
# `bind_adopted_mount/1`) is not reflected in `drives[0]` yet. Deriving
# the name from `state.mounted_ref` instead (via `active_drive?/2`, the
# same predicate `active_drive_first/2` itself uses) picks the right
# drive regardless of list position. `Smbd.share_name/1` is pure but
# still goes through `safe/3`, same as every other seam call: a
# malformed drive map degrades to `nil` (config/1's own default takes
# over) rather than taking the pass down.
defp refresh_share_name(state) do
%{state | share_name: mounted_share_name(state)}
end

defp mounted_share_name(state) do
if mounted?(state) do
case mounted_drive(state) do
drive when is_map(drive) ->
safe(fn -> state.smbd.share_name(drive) end, nil, "Smbd.share_name")

_other ->
nil
end
end
end

# The drive backing the current mount: matched by `mounted_ref` once
# bound, or — for a mount adopted this same pass, before
# `bind_adopted_mount/1` has run — by the device it owns. Same predicate
# `active_drive_first/2` sorts with, reused here so naming and sorting
# never disagree about which drive is "the" mounted one.
defp mounted_drive(state) do
Enum.find(state.drives, &active_drive?(state, &1))
end

# `{:ok, absolute, relative}` — the absolute form is what a caller chowns
# or hands to `smb.conf`; the relative form is what gets persisted.
defp resolve_share_folder(state, path) do
Expand Down Expand Up @@ -2051,12 +2102,14 @@ defmodule UniversalProxy.Storage.Server do
end

defp prepare_runtime(state, credentials) do
params = %{
mount_point: mount_point(state),
share_folder: state.share_folder,
username: credentials.username,
netbios_name: netbios_name(state)
}
params =
%{
mount_point: mount_point(state),
share_folder: state.share_folder,
username: credentials.username,
netbios_name: netbios_name(state)
}
|> maybe_put_share_name(state.share_name)

case safe(
fn -> state.smbd.prepare_runtime(Keyword.put(state.smbd_opts, :params, params)) end,
Expand All @@ -2068,6 +2121,18 @@ defmodule UniversalProxy.Storage.Server do
end
end

# `share_desired?/1` (the only gate on reaching `start_share/1`) implies
# `mounted?/1`, so `state.share_name` should already be a real derived
# name by the time this runs — but a `nil` here (a `Smbd.share_name/1`
# that raised, caught by `refresh_share_name/1`'s `safe/3`) must not
# become an explicit `share_name: nil` in `params`, which `Smbd.config/1`
# would embed as the literal section name `"[]"`. Omitting the key
# instead falls through to `config/1`'s own bare-`"usb_backup"` default.
defp maybe_put_share_name(params, nil), do: params

defp maybe_put_share_name(params, name) when is_binary(name),
do: Map.put(params, :share_name, name)

defp provision_user(state, credentials) do
opts =
state.smbd_opts
Expand Down Expand Up @@ -2199,6 +2264,7 @@ defmodule UniversalProxy.Storage.Server do
mount: state.mounted,
share: state.share,
share_folder: state.share_folder,
share_name: state.share_name,
capacity: state.capacity
}
end
Expand Down
125 changes: 117 additions & 8 deletions lib/universal_proxy/storage/smbd.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ defmodule UniversalProxy.Storage.Smbd do
Generates the hardened `smb.conf`, provisions the SMB account, and
supervises `smbd` for the opt-in USB backup share.

Four parts, all seamed for host tests:

* `config/1` — pure: the complete `smb.conf` text for one
`[usb_backup]` share.
Five parts, all seamed for host tests:

* `share_name/1` (and `share_suffix/1`) — pure: the per-drive share
name (`usb_backup_<suffix>`), so a share is named for the medium it
serves rather than one global name every stick collides on. See
`share_suffix/1` for the derivation.
* `config/1` — pure: the complete `smb.conf` text for one share
(`[usb_backup]` by default, or the `:share_name` a caller passes
in).
* `prepare_runtime/1` — creates the writable dirs and writes the
config (the rootfs and `/etc/samba` are read-only squashfs, so
everything lives under `/data/samba` + `/run/samba`).
Expand Down Expand Up @@ -76,10 +81,109 @@ defmodule UniversalProxy.Storage.Smbd do
required(:username) => String.t(),
required(:netbios_name) => String.t(),
optional(:share_folder) => String.t(),
optional(:share_name) => String.t(),
optional(:data_dir) => String.t(),
optional(:run_dir) => String.t()
}

# The share-suffix regex the design settles on: a conservative,
# SMB-safe charset (no `-`, `_`, or anything Windows/legacy SMB clients
# could mishandle in a share name), 2-16 characters so a bare vid+pid
# fallback (8 chars) and a truncated serial both fit comfortably.
@suffix_re ~r/^[a-z0-9]{2,16}$/
@suffix_min 2
@suffix_max 6

# -- (0) Share naming --

@doc """
The per-drive share name: `#{@share_name}_<suffix>` (see
`share_suffix/1`).

Stable for one physical medium across replugs and ports (same inputs,
same output), and always matches `#{inspect(@suffix_re)}` after the
`#{@share_name}_` prefix — safe to drop straight into `config/1`'s
`:share_name`.

## Examples

iex> Smbd.share_name(%{serial: "1C6F654CED3DED51E92C01E4", vendor_id: 0x0BDA, product_id: 0x0316})
"usb_backup_2c01e4"

iex> Smbd.share_name(%{serial: nil, vendor_id: 0x0930, product_id: 0x6545})
"usb_backup_09306545"
"""
@spec share_name(map()) :: String.t()
def share_name(drive), do: @share_name <> "_" <> share_suffix(drive)

@doc """
The SMB-safe per-drive suffix `share_name/1` appends, mirroring
`Storage.Server`'s drive-key stability semantics (the same USB serial —
or, absent one, the same vendor/product id pair — that makes a drive
key name one physical medium rather than every stick of that model).

With a `:serial` (a `Storage.Probe`-shaped drive map's raw string):
the last #{@suffix_max} characters, lowercased, then sanitized to
`[a-z0-9]` by dropping every other character. If that leaves fewer than
#{@suffix_min} characters (a serial that is all punctuation, or empty),
or there is no serial at all, this falls back to the drive's
`:vendor_id`/`:product_id` (integers, as `Storage.Probe` reports them):
lowercase 4-digit hex, concatenated — always 8 characters, so it can
never itself be too short.

Always matches `#{inspect(@suffix_re)}`.

## Examples

iex> Smbd.share_suffix(%{serial: "1C6F654CED3DED51E92C01E4", vendor_id: 0x0BDA, product_id: 0x0316})
"2c01e4"

iex> Smbd.share_suffix(%{serial: "SN-A", vendor_id: 0x0781, product_id: 0x55AF})
"sna"

iex> Smbd.share_suffix(%{serial: "!!!!!!", vendor_id: 0x0930, product_id: 0x6545})
"09306545"

iex> Smbd.share_suffix(%{serial: nil, vendor_id: 0x0930, product_id: 0x6545})
"09306545"
"""
@spec share_suffix(map()) :: String.t()
def share_suffix(%{serial: serial} = drive) when is_binary(serial) do
serial_suffix(serial) || vidpid_suffix(drive)
end

def share_suffix(drive), do: vidpid_suffix(drive)

# `nil` (not `""`) when sanitizing leaves too little to be a stable,
# readable suffix on its own — the caller falls back to vid+pid, which
# is always #{@suffix_max + 2} characters and therefore always long
# enough.
defp serial_suffix(serial) do
suffix =
serial
|> String.trim()
|> last_chars(@suffix_max)
|> String.downcase()
|> String.replace(~r/[^a-z0-9]/, "")

if String.length(suffix) >= @suffix_min, do: suffix
end

defp last_chars(string, n) do
length = String.length(string)
if length <= n, do: string, else: String.slice(string, length - n, n)
end

defp vidpid_suffix(drive) do
hex_id(Map.get(drive, :vendor_id)) <> hex_id(Map.get(drive, :product_id))
end

defp hex_id(id) when is_integer(id) and id >= 0 do
id |> Integer.to_string(16) |> String.downcase() |> String.pad_leading(4, "0")
end

defp hex_id(_id), do: "0000"

# -- (a) Pure config generation --

@doc """
Expand All @@ -89,9 +193,13 @@ defmodule UniversalProxy.Storage.Smbd do
(the Unix and SMB account), `:netbios_name` (server identity shown to
clients). Optional `:share_folder` (default `"/"`) maps the share at a
subdirectory of the drive rather than its root, so the share's `path`
becomes `<mount_point>/<share_folder>`; `:data_dir` (default
`#{@default_data_dir}`) and `:run_dir` (default `#{@default_run_dir}`)
relocate Samba's state.
becomes `<mount_point>/<share_folder>`; `:share_name` (default
`#{@share_name}`, kept for callers that predate per-drive naming —
`UniversalProxy.Storage.Server` always derives and passes one via
`share_name/1`) names the `[…]` section itself, so a share is per-drive
rather than one global name every stick collides on; `:data_dir`
(default `#{@default_data_dir}`) and `:run_dir` (default
`#{@default_run_dir}`) relocate Samba's state.

The folder is joined, not validated: `UniversalProxy.Storage.Server`
owns validating that it is a sandboxed, existing directory before it
Expand All @@ -108,6 +216,7 @@ defmodule UniversalProxy.Storage.Smbd do

username = sanitize(username)
share_path = share_path(mount_point, Map.get(params, :share_folder, @default_share_folder))
share_name = params |> Map.get(:share_name, @share_name) |> sanitize()

"""
# Generated by UniversalProxy.Storage.Smbd — regenerated on every start,
Expand Down Expand Up @@ -149,7 +258,7 @@ defmodule UniversalProxy.Storage.Smbd do
ncalrpc dir = #{Path.join(run_dir, "ncalrpc")}
log level = 1

[#{@share_name}]
[#{share_name}]
path = #{share_path}
valid users = #{username}
force user = #{username}
Expand Down
11 changes: 6 additions & 5 deletions lib/universal_proxy_web/components/storage.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ defmodule UniversalProxyWeb.Components.Storage do
import UniversalProxyWeb.Components.Icons
import UniversalProxyWeb.Components.UI

# The share name `Storage.Smbd` writes into smb.conf. Mirrored here for
# the drawer's "Share" readout only; the daemon remains the source of
# truth.
@share_name "usb_backup"
# Rendered only when the live payload's `:share_name` is somehow absent
# (a payload built by hand, or a subsystem that hasn't derived one yet)
# — `Storage.Server`/`Storage.Smbd` are the source of truth for the real,
# per-drive name.
@fallback_share_name "usb_backup"

@root_folder "/"

Expand Down Expand Up @@ -237,7 +238,7 @@ defmodule UniversalProxyWeb.Components.Storage do
|> assign(:dirty?, assigns.first? and fs_dirty?(mount, assigns.drive))
|> assign(:shared?, assigns.first? and assigns.storage.share == :running)
|> assign(:server_host, server_host(assigns.host))
|> assign(:share_name, @share_name)
|> assign(:share_name, Map.get(assigns.storage, :share_name) || @fallback_share_name)

~H"""
<div class="fixed inset-0 z-[90] flex justify-end animate-fade">
Expand Down
9 changes: 8 additions & 1 deletion test/support/storage_stub.ex
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,14 @@ defmodule UniversalProxy.StorageStub do
# The LiveView seeds from `state/0` on mount; tests push real drives in
# afterwards over PubSub, so the seed is deliberately empty.
def state,
do: %{drives: [], mount: nil, share: :off, share_folder: "/", capacity: nil}
do: %{
drives: [],
mount: nil,
share: :off,
share_folder: "/",
share_name: nil,
capacity: nil
}

def list_drives, do: state().drives

Expand Down
Loading
Loading