Skip to content

The admin API

The gRPC admin API that creates, changes, disables and grants shares while the server runs, where it listens, and what it refuses.

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, DeleteSharedefine 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, EnableSharetake a share offline and back
Grant, Revokegive a subject read or write access, or take it away
ListShares, GetShareevery share, with what it serves, over which protocols, and why not the others
ListUsers, ListGroupswho a grant can name; each user with the protocols their credentials can answer
ReloadDirectoryread the people again, now — see reading the directory again
GetServerInfothe name, version, start time, generation and listeners
ListParents, CreateVolume, ResizeVolume, SnapshotVolume, DeleteVolume, GetVolume, ListVolumesstorage 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
listena 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
audiencethe aud a token must name: what this server is called at that provider; required
subjects, groupswho may call: a sub, or a group in the groups claim; at least one of the two
jwks_urlskips OIDC discovery, for a provider that does not publish it
groups_claimthe 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

  • --isolate does not go with an admin block — nor a metrics block — yet: there is no one process a change could be applied to. The combination is refused.
  • A binary built with -tags nogrpc has no admin API, and a configuration with an admin block 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.proto

Created on by tannevaled

Modified on by tannevaled