Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
66 changes: 60 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,31 @@ 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`) pins the mounted
# drive at `drives[0]` for the whole pass, so by the time this step runs
# — after `reconcile_mount/1` — that position is the drive to derive the
# share name from, whenever something is mounted at all. `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 List.first(state.drives) do
drive when is_map(drive) ->
safe(fn -> state.smbd.share_name(drive) end, nil, "Smbd.share_name")

_other ->
nil
end
end
end
Comment thread
bbangert marked this conversation as resolved.

# `{: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 +2090,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 +2109,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 +2252,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