The admin API
Since v0.8.0 the shares can change while the server runs. An admin block
turns on a gRPC service that creates, changes and takes shares offline without
a restart and without editing a file.
admin {
listen = "unix:///run/fileshare/admin.sock" # made 0600
state_file = "/var/lib/fileshare/shares.json"
source_roots = ["/srv/images", "/data"]
}Nothing listens unless the block is written.
What it does
The service is
fileshare.admin.v1.AdminService:
CreateShare, UpdateShare, DeleteShare | define a share from an image, a directory or (since v0.21.0) a volume; change read_only or protocols (its source cannot change: delete it and create another) |
DisableShare, EnableShare | take a share offline and back |
Grant, Revoke | give a subject read or write access, or take it away |
ListShares, GetShare | every share, with what it serves, over which protocols, and why not the others |
ListUsers, ListGroups | who a grant can name; each user with the protocols their credentials can answer |
ReloadDirectory | read the people again, now — see reading the directory again |
GetServerInfo | the name, version, start time, generation and listeners |
ListParents, CreateVolume, ResizeVolume, SnapshotVolume, DeleteVolume, GetVolume, ListVolumes | storage created through a privileged provisioner, and shares served from it — see volumes (since v0.21.0) |
A grant names a user, a group (@group in a configuration file), an
oidc:groups: value or an oidc:user: name — the vocabulary the
configuration file uses, see
people the identity provider names.
grpc.health.v1 answers on the same listener. reflection = true in the block
turns on gRPC server reflection, for grpcurl.
Every change says what serving it did
A change answers with an Applied message:
message Applied {
uint64 generation = 1; // the generation now being served
uint64 connections_closed = 2; // how many the previous one had open, and closed
}That is why every RPC has a response message of its own rather than returning
the share itself: the .proto follows buf’s STANDARD
lint rules, checked by buf lint in the fileshare CI, over AIP-131’s
“return the resource” — the wrapper is what carries Applied.
Where it listens, and who may call it
Over TCP it is mutual TLS or nothing
tls_cert_file, tls_key_file and client_ca_file, all three,
loopback included: any local user can reach loopback, and this API
decides who reads whose files. A unix socket is made 0600, and its
permissions are its access control.
admin {
listen = "127.0.0.1:7443"
tls_cert_file = "/etc/fileshare/admin/server.pem"
tls_key_file = "/etc/fileshare/admin/server.key"
client_ca_file = "/etc/fileshare/admin/clients.pem"
state_file = "/var/lib/fileshare/shares.json"
source_roots = ["/srv/images"]
}The listener is grpc-transports/control. Each change is logged with who made it: the client certificate’s CN, or the socket peer’s uid.
Over HTTPS, for OIDC tokens
Since v0.28.0 the same API — the same state, the same audit — is also served
over HTTPS when the admin block has a web block: Connect, gRPC-Web and
gRPC on one listener, with the tls block’s
certificate. It is what the web and native UIs talk to.
admin {
listen = "unix:///run/fileshare/admin.sock"
state_file = "/var/lib/fileshare/shares.json"
web {
listen = "0.0.0.0:8443"
issuer "https://login.example.org" {
audience = "fileshare-a" # what THIS server is called there
groups = ["fileshare-admins"]
}
issuer "https://login.partner.example.org" {
audience = "fileshare-a.partner"
subjects = ["5b0c…"] # a person is (issuer, sub)
}
}
}| field | |
|---|---|
listen | a TCP address; refused without a tls block — a bearer token is as good as a password |
issuer "<url>" | one block per identity provider, its iss; given twice is refused |
audience | the aud a token must name: what this server is called at that provider; required |
subjects, groups | who may call: a sub, or a group in the groups claim; at least one of the two |
jwks_url | skips OIDC discovery, for a provider that does not publish it |
groups_claim | the claim that carries the groups; default groups |
Who is answered. A call is answered when one issuer’s verifier accepts its
bearer token — signature, iss, an aud naming this server, times — and
that issuer’s block names the token’s subject or one of its groups. A subject
listed at one issuer is nobody at another: a person is (issuer, sub). The
check runs on the headers alone, before a byte of the message is decoded.
One audience per server. A token addressed to several servers could be replayed by any of them to the others (RFC 8707, RFC 9068), so each server has its own, and a UI driving several servers asks for one token per server.
What a refused caller learns. “Refused”, and nothing more. The reason, and
every change made, goes to the audit output as oidc=<issuer> <sub>.
No CORS. Browsers do not call it directly: they reach it through the UIs' own server, which holds the tokens. Requests are bounded to 1 MiB and every phase of a connection has a timeout. Each issuer is contacted at start, to read its keys; one that cannot be reached stops the start, naming it.
In a browser: the console
go-fileshare/console is the admin console that calls this API: shares, details, grants, create, enable, disable, delete, grant and revoke, and volumes (create, resize, snapshot, share, delete – never destroying data), over every server at once. It runs in a browser behind go-fileshare/portal, which logs people in with OpenID Connect and keeps their tokens: one per server, addressed to that server alone. The browser never holds one.
# portal.hcl
server "paris" {
url = "https://fs-paris.example.org:8443" # this web listener
issuer = "https://login.example.org"
resource = "https://fs-paris.example.org/" # this server's audience above
}
ui "console" { dir = "/usr/share/fileshare-console" }The same console also runs natively, with no portal. It logs in through the
system browser and a loopback listener, as a public client
(go-authn/rp, RFC 8252), and calls each server
with a token for that server alone. The servers are listed in its own
console.hcl, with the same url and resource as here.
The file explorer, go-fileshare/explorer,
runs behind the same portal: browse, upload, download, rename, copy and delete,
on every share a person may use. It reaches each server’s WebDAV through the
portal (/dav/<server>/...) with a token for that server, and the portal serves
what comes back inert (Content-Security-Policy: sandbox, an attachment):
a page on a share never runs as the portal. It needs fileshare v0.29.0 or
later, whose WebDAV
lists the shares at its root and takes an upload in pieces.
# portal.hcl, the same server
server "paris" {
# ... as above, and its WebDAV:
dav_url = "https://fs-paris.example.org"
}
ui "explorer" { dir = "/usr/share/fileshare-explorer" }The explorer also runs natively (explorer v0.2.0), with no portal, as the
console does: it logs in through the system browser as a public client and
calls each server’s WebDAV with a token for that server alone. Its servers are
listed in explorer.hcl, each with its dav_url (https only) and the
resource of its oidc block. Neither native client follows a redirect with
a token: Go’s HTTP client keeps Authorization across one to the same host on
any scheme, which a misconfigured proxy answering a 307 to a plain-http address would turn
into a token sent in clear.
An upload that fails resumes (explorer v0.3.0). The part file is named after the file – its name, size and modification time – so uploading the same file again continues where the part ends, once the part’s last 64 KiB, read back, are found to be the file’s. A different file is never continued. A failure of the network or the server keeps the part; a refusal (403, a full share: 507) removes it.
Each server’s audience is the portal’s resource for it. Writing a console
of your own is not required to use the API: any Connect, gRPC-Web or gRPC client
with a token for this server will do.
What it will and will not touch
It manages the shares it created. A share written in the configuration is
listed, with the same fields, and a change to its definition is refused with
FAILED_PRECONDITION: a share defined in two places is a question nobody wants
to answer, and the file is where that one is defined. The API’s shares live in
state_file, written atomically, and are served again at the next start — which
is why a block without state_file is refused.
A source must lie under source_roots, resolved — links followed, ..
taken out — and it is the resolved path that is kept. Without source_roots the
API cannot create shares at all: the process can read /dev and /etc,
and nobody meant to hand those to a caller. It is checked again at every start,
and a share in state_file that is no longer under a root stops the start,
naming it.
Every share has at least one grant. A share with none is open to anyone who
authenticates. The file may say that on purpose; an API call should not say it
by omission. So CreateShare needs a grant, and revoking the last one is
refused — delete the share instead.
Names a protocol can carry. A share name is at most 80 characters, does
not begin or end with a space, is not . or .., and holds no unprintable
character and none of :*?"<>|{}%. Subjects hold no unprintable character
either, and one share takes at most 1000 grants — a group’s job long before.
Each of these was accepted before v0.14.0, written to state_file, and then
fatal at every start.
No share may contain the configuration, the state file, or the secrets
they name: whoever writes into it would rewrite who may do what. Since v0.17.0
that includes authorized_keys_file, a users block’s dsn_file and
bind_password_file, the ssf block’s ca_file, and a sqlite database a DSN
names: a writer who could add their key to somebody’s authorized_keys would
log in as them. Nor may a share contain another share (see
directories).
A change is checked like a configuration, opened, written down, then served. A change the server cannot honour — an image that will not open, a name nobody in the directory has — is refused, with what was served and what was written left as they were.
Disabling a share
Disabling is Samba’s available = no: the share stays defined and every
attempt to connect fails; its open connections are closed and its image or
directory is let go of, so the file can be replaced while it is offline.
EnableShare serves it again, and is refused when its source can no longer be
opened. CreateShare can also create a share already disabled.
Unlike every other change it applies to a share of the configuration too —
taking a share offline is an operation, not a definition — and it survives a
restart: it is kept in the state file. fileshare check
lists what is offline.
A change is a new generation
A change restarts the protocol servers, and closes their connections
SMB checks who may connect once per tree connect, and SFTP builds a person’s tree once per login, so a session that outlived a revocation would keep the access just taken away. “Revoked, except for whoever was already connected” is not a revocation.
So a change is not applied to the running servers: they are replaced by new
ones built from the new list — a generation — the way a restart would,
without giving up the ports. The libraries could not do otherwise:
go-filesystems/smb has Share and no Unshare, nfs has Export and no
Unexport.
The ports stay bound, so a client connecting during a change waits instead of being refused. Images whose share did not change keep their driver, never opened a second time. Clients reconnect; that is the price, paid for every change — which is why the API applies one change per call rather than one per field.
Limits
--isolatedoes not go with anadminblock — nor ametricsblock — yet: there is no one process a change could be applied to. The combination is refused.- A binary built with
-tags nogrpchas no admin API, and a configuration with anadminblock is refused rather than served without one. That tag saves 11.7 MB; see building only what you want.
Changing the .proto
The Go code under proto/ is generated and committed, so go install needs no
protoc. After changing a .proto — the admin API’s, or the provisioner’s
proto/fileshare/provision/v1/provision.proto — regenerate both with the
versions the fileshare CI pins (protoc 34.1, protoc-gen-go v1.36.12,
protoc-gen-go-grpc v1.6.2). CI regenerates them and fails when the committed
code differs, or when generated code was left uncommitted:
protoc -I proto --go_out=. --go_opt=module=github.com/go-fileshare/fileshare \
--go-grpc_out=. --go-grpc_opt=module=github.com/go-fileshare/fileshare \
proto/fileshare/admin/v1/admin.proto proto/fileshare/provision/v1/provision.protoCreated on by tannevaled
Modified on by tannevaled