defmodule Sftpd do
@moduledoc """
A pluggable SFTP server with support for multiple storage backends.
Sftpd wraps Erlang's `:ssh_sftpd` module and provides a clean API for
starting SFTP servers with configurable authentication and storage backends.
OTP 29 no longer enables the SFTP subsystem implicitly when starting an SSH
daemon. `Sftpd.start_server/1` passes an explicit
`:ssh_sftpd.subsystem_spec/1` to `:ssh.daemon/2`, so callers do not need to
configure the OTP daemon subsystem list themselves.
OTP 29 also disables shell and exec services by default. `Sftpd` is an
SFTP-only wrapper and does not enable remote shell or exec channels.
## Quick Start
# Start an SFTP server with the in-memory backend
{:ok, ref} = Sftpd.start_server(
port: 2222,
backend: Sftpd.Backends.Memory,
backend_opts: [],
auth: {:passwords, [{"dev", "dev"}]},
system_dir: "ssh_keys"
)
## Backends
Sftpd supports pluggable backends. Built-in backends:
- `Sftpd.Backends.Memory` - in-memory storage for development and tests
- `Sftpd.Backends.S3` - optional Amazon S3 or S3-compatible storage
To create a custom backend, implement the `Sftpd.Backend` behaviour.
See the HexDocs extras [Backends](backends.html) and
[Custom Backends](custom_backends.html) for package-level guidance and
examples. If you need persistent object storage, see the S3 backend docs for
the optional dependency set required by that backend.
## Guides
Package-level HexDocs extras:
- [Getting Started](getting_started.html)
- [Phoenix Setup](phoenix.html)
- [Backends](backends.html)
- [Custom Backends](custom_backends.html)
- [Telemetry](telemetry.html)
## Options
- `:port` - Port to listen on (default: 22)
- `:backend` - Backend module, `{:genserver, pid_or_name}`, or
`{:genserver, pid_or_name, session: true}` (required)
- `:backend_opts` - Options passed to `backend.init/1` for module backends (default: [])
The built-in S3 backend accepts `:bucket`, `:prefix`, and `:aws_client`.
- `:auth` - Authentication config, either `{:passwords, list}` or `{Module, opts}` (required)
- `:system_dir` - Directory containing SSH host keys (required)
- `:max_sessions` - Maximum concurrent sessions (default: 10)
- `:open_timeout` - Timeout in milliseconds for opening files (default: 30000)
- `:close_timeout` - Timeout in milliseconds for finalizing file closes (default: 30000)
## Process-Based Backends
Instead of a module, you can use a running GenServer:
{:ok, backend_pid} = MyBackendServer.start_link()
Sftpd.start_server(backend: {:genserver, backend_pid}, ...)
See `Sftpd.Backend` for the messages your GenServer must handle.
## Telemetry
See `Sftpd.Telemetry` and the `Telemetry` extra in HexDocs for the event
reference emitted by the server and file-handler layers.
## SSH Host Keys
You need SSH host keys for the server. Generate them with:
ssh-keygen -t rsa -f ssh_host_rsa_key -N ""
ssh-keygen -t ecdsa -f ssh_host_ecdsa_key -N ""
Then set `:system_dir` to the directory containing these keys.
"""
@default_port 22
@default_max_sessions 10
@server_event_prefix [:sftpd, :server]
@type server_ref :: :ssh.daemon_ref()
@doc """
Start an SFTP server.
## Examples
# Start with the in-memory backend
{:ok, ref} = Sftpd.start_server(
port: 2222,
backend: Sftpd.Backends.Memory,
backend_opts: [],
auth: {:passwords, [{"admin", "secret"}]},
system_dir: "ssh_keys"
)
## Options
See module documentation for full list of options.
"""
@spec start_server(keyword()) :: {:ok, server_ref()} | {:error, term()}
def start_server(opts) do
cond do
Keyword.has_key?(opts, :users) ->
{:error, {:deprecated_option, :users}}
not Keyword.has_key?(opts, :auth) ->
{:error, {:missing_option, :auth}}
true ->
do_start_server(opts)
end
end
defp do_start_server(opts) do
port = Keyword.get(opts, :port, @default_port)
backend = Keyword.fetch!(opts, :backend)
backend_opts = Keyword.get(opts, :backend_opts, [])
auth = Keyword.fetch!(opts, :auth)
system_dir = Keyword.fetch!(opts, :system_dir)
max_sessions = Keyword.get(opts, :max_sessions, @default_max_sessions)
open_timeout = Keyword.get(opts, :open_timeout, 30_000)
close_timeout = Keyword.get(opts, :close_timeout, 30_000)
metadata = %{
port: port,
max_sessions: max_sessions,
backend: backend_name(backend),
backend_kind: backend_kind(backend)
}
Sftpd.Telemetry.span(
@server_event_prefix ++ [:start],
metadata,
fn ->
with :ok <- Sftpd.Auth.Registry.ensure_started(),
:ok <- validate_auth(auth),
{:ok, {backend, backend_state}} <- init_backend(backend, backend_opts) do
:ssh.daemon(port, [
{:max_sessions, max_sessions},
{:pwdfun, Sftpd.Auth.Adapter.password_fun(auth)},
{:key_cb, {Sftpd.Auth.KeyCallback, [auth: auth]}},
{:system_dir, to_charlist(system_dir)},
{:subsystems,
[
Sftpd.Subsystem.subsystem_spec(
cwd: ~c"/",
root: ~c"/",
file_handler: {
Sftpd.FileHandler,
%{
backend: backend,
backend_state: backend_state,
open_timeout: open_timeout,
close_timeout: close_timeout
}
}
)
]}
])
end
end,
&server_finalize/2
)
end
@doc """
Return a child spec for supervising an SFTP server.
This lets applications start the server directly from a supervision tree:
children = [
{Sftpd,
port: 2222,
backend: Sftpd.Backends.Memory,
backend_opts: [],
auth: {:passwords, [{"dev", "dev"}]},
system_dir: "ssh_keys"}
]
"""
@spec child_spec(keyword()) :: Supervisor.child_spec()
def child_spec(opts) do
%{
id: Keyword.get(opts, :id, __MODULE__),
start: {Sftpd.Server, :start_link, [opts]},
type: :worker,
restart: :permanent,
shutdown: 5_000
}
end
defp init_backend({:genserver, server}, _opts) do
# Process-based backend - no init needed, process manages own state
{:ok, {{:genserver, server}, nil}}
end
defp init_backend({:genserver, server, opts}, _opts) when is_list(opts) do
# Process-based backend - no init needed, process manages own state
{:ok, {{:genserver, server, opts}, nil}}
end
defp init_backend(module, opts) when is_atom(module) do
# Module-based backend - call init/1
case module.init(opts) do
{:ok, state} -> {:ok, {module, state}}
{:error, reason} -> {:error, reason}
end
end
defp validate_auth(auth) do
if Sftpd.Auth.Adapter.valid_config?(auth) do
:ok
else
{:error, {:invalid_option, :auth}}
end
end
@doc """
Stop an SFTP server.
## Examples
{:ok, ref} = Sftpd.start_server(opts)
:ok = Sftpd.stop_server(ref)
"""
@spec stop_server(server_ref()) :: :ok | {:error, term()}
def stop_server(ref) do
Sftpd.Telemetry.span(
@server_event_prefix ++ [:stop],
%{server_ref: ref},
fn ->
:ssh.stop_daemon(ref)
end,
&stop_finalize/2
)
end
defp server_finalize({:ok, ref}, duration),
do: {%{duration: duration}, %{result: :ok, server_ref: ref}}
defp server_finalize({:error, reason}, duration),
do: {%{duration: duration}, %{result: :error, reason: reason}}
defp stop_finalize(:ok, duration), do: {%{duration: duration}, %{result: :ok}}
defp stop_finalize({:error, reason}, duration),
do: {%{duration: duration}, %{result: :error, reason: reason}}
defp backend_kind({:genserver, _server}), do: :genserver
defp backend_kind({:genserver, _server, _opts}), do: :genserver
defp backend_kind(module) when is_atom(module), do: :module
defp backend_name({:genserver, server}), do: inspect(server)
defp backend_name({:genserver, server, _opts}), do: inspect(server)
defp backend_name(module) when is_atom(module), do: module
end