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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,14 @@ After `DELETE /instance`, a copy of the sealed state that the supervisor still h

One process per person, not one process for many people: the operating system keeps each person's keys in a separate address space, the proxy code is unchanged, and it matches one container per person at Cloudflare. An idle instance measured on Linux x64 (Go 1.24, one service, one role): about 12 MB resident, 8 threads, and 0 CPU ticks over 30 idle seconds.

Limits of this version: daily counts and open consent links are in memory, so a restart resets them (as with `keyring serve`); #50 tracks keeping them across a container's sleep. The proxy listener still refuses a wildcard address (#51).
Limits of this version: daily counts and open consent links are in memory, so a restart resets them (as with `keyring serve`); #50 tracks keeping them across a container's sleep.

**Container mode** (`--container`, #51) is for an instance in a container that only its supervisor can reach, such as a Cloudflare Container behind the person's Durable Object. In this mode:
- the admin API, the proxy and the consent listeners may use a wildcard address such as `0.0.0.0:8080`, and no two may share a port;
- `allow_sources` and `consent.api_allow_sources` must be empty, and no source address is checked, because every request arrives through the supervisor. The supervisor is the network boundary. It checks the router's proof (`X-Keyring-Caller`, #52) and the admin token before forwarding anything;
- the keyring never forwards `X-Keyring-Caller` upstream, even if the supervisor failed to remove it.

Container mode is set only by the flag; a rules or access file cannot turn it on, and `keyring serve` never uses it.

### Ed25519 signing (not an API proxy)

Expand Down
4 changes: 2 additions & 2 deletions cmd/keyring/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (

// runInstance serves one person's keyring until SIGTERM or until it is
// deleted through its admin API.
func runInstance(adminListen, adminTokenSHA, statePath, auditPath string, stdout, stderr io.Writer) error {
func runInstance(adminListen, adminTokenSHA, statePath, auditPath string, container bool, stdout, stderr io.Writer) error {
audit, auditName := stdout, "stdout"
if auditPath != "" {
f, err := openAudit(auditPath)
Expand All @@ -29,7 +29,7 @@ func runInstance(adminListen, adminTokenSHA, statePath, auditPath string, stdout
}
in, err := instance.New(instance.Options{
AdminListen: adminListen, AdminTokenSHA256: adminTokenSHA, StatePath: statePath,
Audit: audit, AuditName: auditName, Log: stderr,
Audit: audit, AuditName: auditName, Log: stderr, Container: container,
})
if err != nil {
return err
Expand Down
5 changes: 3 additions & 2 deletions cmd/keyring/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import (

const usage = `Usage:
keyring serve --config FILE --store FILE run the keyring
keyring instance --admin-listen ADDR --admin-token-sha256 HEX [--state FILE] [--audit FILE]
keyring instance --admin-listen ADDR --admin-token-sha256 HEX [--state FILE] [--audit FILE] [--container]
run one person's keyring, configured only through its admin
API, its state sealed with a data key from the supervisor
keyring check --config FILE [--store FILE] check the rules and access file (and which keys are missing)
Expand Down Expand Up @@ -88,6 +88,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
adminTokenSHA := fs.String("admin-token-sha256", "", "instance: hex SHA-256 of the supervisor's admin token")
statePath := fs.String("state", "", "instance: keep the sealed state in this file (default: memory only)")
auditPath := fs.String("audit", "", "instance: audit log file (default: stdout)")
containerMode := fs.Bool("container", false, "instance: run in a container reached only through its supervisor (wildcard listen, no source check)")
if err := fs.Parse(rest); err != nil {
return 2
}
Expand All @@ -111,7 +112,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "keyring instance: pass --admin-listen and --admin-token-sha256")
return 2
}
err = runInstance(*adminListen, *adminTokenSHA, *statePath, *auditPath, stdout, stderr)
err = runInstance(*adminListen, *adminTokenSHA, *statePath, *auditPath, *containerMode, stdout, stderr)
case "serve":
if !need("config", "store") || fs.NArg() != 0 {
return 2
Expand Down
17 changes: 14 additions & 3 deletions internal/instance/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ type Options struct {
AuditName string
// Log receives operational messages (listeners started and stopped).
Log io.Writer
// Container runs the instance inside a container that only its
// supervisor can reach (policy.Config.ContainerMode): listeners may use
// a wildcard address and no source address is checked. Off by default.
Container bool
}

// Instance is one person's keyring.
Expand Down Expand Up @@ -75,7 +79,13 @@ type running struct {
// AdminHandler) until it is created or unlocked.
func New(opt Options) (*Instance, error) {
admin, err := netip.ParseAddrPort(opt.AdminListen)
if err != nil || !admin.Addr().IsLoopback() || admin.Port() == 0 {
if opt.Container {
// The supervisor reaches the container over its own network
// interface, not loopback; the admin token still guards every call.
if err != nil || admin.Port() == 0 {
return nil, fmt.Errorf("admin listen %q: want IP:port", opt.AdminListen)
}
} else if err != nil || !admin.Addr().IsLoopback() || admin.Port() == 0 {
return nil, fmt.Errorf("admin listen %q: must be a loopback IP:port", opt.AdminListen)
}
if len(opt.AdminTokenSHA256) != 64 || strings.Trim(opt.AdminTokenSHA256, "0123456789abcdef") != "" {
Expand Down Expand Up @@ -137,7 +147,8 @@ func (in *Instance) wanted(cfg *policy.Config) []listenerSpec {
func (in *Instance) bind(cfg *policy.Config) (map[string]net.Listener, error) {
opened := map[string]net.Listener{}
for _, s := range in.wanted(cfg) {
if s.addr == in.opt.AdminListen {
if addr, err := netip.ParseAddrPort(s.addr); err == nil && addr.Port() == in.admin.Port() &&
(addr.Addr() == in.admin.Addr() || addr.Addr().IsUnspecified() || in.admin.Addr().IsUnspecified()) {
closeAll(opened)
return nil, fmt.Errorf("%s cannot use the admin address", s.name)
}
Expand Down Expand Up @@ -234,7 +245,7 @@ func badRequest(format string, args ...any) error {
// and closes listeners no longer used. On any error nothing changes.
// Callers hold in.mu.
func (in *Instance) commit(next *State, key []byte) error {
cfg, err := next.config(in.opt.AuditName)
cfg, err := next.config(in.opt.AuditName, in.opt.Container)
if err != nil {
return badRequest("%v", err)
}
Expand Down
29 changes: 29 additions & 0 deletions internal/instance/instance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -500,3 +500,32 @@ func TestConsentLinkStoresIntoTheSealedState(t *testing.T) {
t.Fatal("audit log holds the pasted value")
}
}

func TestContainerOption(t *testing.T) {
if _, err := New(Options{AdminListen: "0.0.0.0:7720", AdminTokenSHA256: sha(adminToken), Audit: io.Discard}); err == nil {
t.Fatal("wildcard admin accepted without --container")
}
_, port, _ := net.SplitHostPort(freeAddr(t))
in, err := New(Options{AdminListen: "0.0.0.0:" + port, AdminTokenSHA256: sha(adminToken), Audit: io.Discard, Container: true})
if err != nil {
t.Fatal(err)
}
t.Cleanup(in.Close)
x := &inst{t: t, in: in, admin: in.AdminHandler(), audit: &syncBuffer{}}
_, proxyPort, _ := net.SplitHostPort(freeAddr(t))
// The proxy cannot take the admin port, whatever the address.
if code, _ := x.call("POST", "/create", map[string]any{"id": "c1", "data_key": newKey(t), "listen": "127.0.0.1:" + port}); code != http.StatusBadRequest {
t.Fatalf("proxy on the admin port: %d", code)
}
if code, out := x.call("POST", "/create", map[string]any{"id": "c1", "data_key": newKey(t), "listen": "0.0.0.0:" + proxyPort, "allow_sources": []string{"10.0.0.1"}}); code != http.StatusBadRequest {
t.Fatalf("allow_sources in container mode: %d %v", code, out)
}
up := upstreamEcho(t)
x.must("POST", "/create", map[string]any{"id": "c1", "data_key": newKey(t), "listen": "0.0.0.0:" + proxyPort})
x.must("PUT", "/services/api", map[string]any{"base": up.URL, "key": "API_KEY", "auth": "bearer"})
x.must("PUT", "/keys/API_KEY", map[string]any{"value": "container-value"})
token := x.must("PUT", "/roles/agent", map[string]any{"access": map[string]any{"api": map[string]any{"paths": []string{"/"}}}})["token"].(string)
if code, _ := proxyGet(t, "127.0.0.1:"+proxyPort, token, "/api/x"); code != 200 {
t.Fatalf("container proxy: %d", code)
}
}
4 changes: 3 additions & 1 deletion internal/instance/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ func (s *State) clone() *State {

// config builds and validates the proxy settings for s. auditName only
// fills the field Validate requires; the instance writes its own audit.
func (s *State) config(auditName string) (*policy.Config, error) {
// container sets policy.Config.ContainerMode.
func (s *State) config(auditName string, container bool) (*policy.Config, error) {
// Round-trip through JSON so the config never shares maps or pointers
// with the state, and every parsed field is rebuilt by Validate.
raw, err := json.Marshal(struct {
Expand All @@ -91,6 +92,7 @@ func (s *State) config(auditName string) (*policy.Config, error) {
if err := dec.Decode(&cfg); err != nil {
return nil, err
}
cfg.ContainerMode = container
if err := cfg.Validate(); err != nil {
return nil, err
}
Expand Down
94 changes: 70 additions & 24 deletions internal/policy/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ type Consent struct {

public *url.URL
apiSources []netip.Prefix
anySource bool // container mode: no source check (see Config.ContainerMode)
}

// Equal reports whether a and b are the same settings (nil is off).
Expand All @@ -115,6 +116,9 @@ func (a *Consent) PublicBase() *url.URL {

// APISourceAllowed reports whether addr may call the consent API.
func (a *Consent) APISourceAllowed(addr netip.Addr) bool {
if a.anySource {
return true
}
addr = addr.Unmap()
for _, p := range a.apiSources {
if p.Contains(addr) {
Expand Down Expand Up @@ -142,9 +146,56 @@ type Config struct {
Rules
AccessList

// ContainerMode is set only by "keyring instance --container", never by
// a file: the instance runs in a container reached only through its
// supervisor (at Cloudflare, the person's Durable Object), so it may
// listen on a wildcard address and checks no source address. See
// CallerHeader.
ContainerMode bool `json:"-"`

sources []netip.Prefix
}

// CallerHeader carries the router's proof of identity to the supervisor in
// container mode. The supervisor checks and removes it; the keyring never
// forwards it upstream either.
const CallerHeader = "X-Keyring-Caller"

// listenAddr parses an IP:port a listener uses. A wildcard address is only
// allowed in container mode.
func (c *Config) listenAddr(field, value string) (netip.AddrPort, error) {
addr, err := netip.ParseAddrPort(value)
if err != nil || addr.Port() == 0 {
return addr, fmt.Errorf("%s %q: want IP:port", field, value)
}
if addr.Addr().IsUnspecified() && !c.ContainerMode {
return addr, fmt.Errorf("%s %q: use one specific address, not a wildcard", field, value)
}
return addr, nil
}

// sameSocket reports whether two listeners would need the same port on the
// same address; a wildcard address holds the port on every address.
func sameSocket(a, b netip.AddrPort) bool {
return a.Port() == b.Port() && (a.Addr() == b.Addr() || a.Addr().IsUnspecified() || b.Addr().IsUnspecified())
}

// sourcesFor parses an allow list. In container mode the list must be empty:
// every request arrives through the supervisor, so a source address proves
// nothing.
func (c *Config) sourcesFor(field string, list []string) ([]netip.Prefix, error) {
if c.ContainerMode {
if len(list) > 0 {
return nil, fmt.Errorf("%s must be empty in container mode: requests arrive only through the supervisor", field)
}
return nil, nil
}
if len(list) == 0 {
return nil, fmt.Errorf("%s is empty", field)
}
return parseSources(field, list)
}

type Service struct {
// Base is the API origin (and optional base path) requests are sent to.
Base string `json:"base,omitempty"`
Expand Down Expand Up @@ -401,17 +452,11 @@ func decodeStrict(raw []byte, v any) error {

// Validate checks every field and prepares the parsed forms.
func (c *Config) Validate() error {
listen, err := netip.ParseAddrPort(c.Listen)
listen, err := c.listenAddr("listen", c.Listen)
if err != nil {
return fmt.Errorf("listen %q: want IP:port", c.Listen)
}
if listen.Addr().IsUnspecified() {
return fmt.Errorf("listen %q: use one specific address, not a wildcard", c.Listen)
}
if len(c.AllowSources) == 0 {
return errors.New("allow_sources is empty")
return err
}
if c.sources, err = parseSources("allow_sources", c.AllowSources); err != nil {
if c.sources, err = c.sourcesFor("allow_sources", c.AllowSources); err != nil {
return err
}
if strings.TrimSpace(c.AuditLog) == "" {
Expand Down Expand Up @@ -446,7 +491,7 @@ func (c *Config) Validate() error {
}
}
if k := c.Consent; k != nil {
if err := k.validate(listen, c.AdminListen); err != nil {
if err := k.validate(c, listen); err != nil {
return err
}
}
Expand Down Expand Up @@ -552,22 +597,22 @@ func parseSources(field string, list []string) ([]netip.Prefix, error) {
return out, nil
}

func (k *Consent) validate(proxy netip.AddrPort, adminListen string) error {
pages, err := netip.ParseAddrPort(k.Listen)
if err != nil || pages.Addr().IsUnspecified() || pages.Port() == 0 {
return fmt.Errorf("consent listen %q: want one specific IP:port", k.Listen)
func (k *Consent) validate(c *Config, proxy netip.AddrPort) error {
pages, err := c.listenAddr("consent listen", k.Listen)
if err != nil {
return err
}
api, err := netip.ParseAddrPort(k.APIListen)
if err != nil || api.Addr().IsUnspecified() || api.Port() == 0 {
return fmt.Errorf("consent api_listen %q: want one specific IP:port", k.APIListen)
api, err := c.listenAddr("consent api_listen", k.APIListen)
if err != nil {
return err
}
admin, _ := netip.ParseAddrPort(adminListen)
admin, _ := netip.ParseAddrPort(c.AdminListen)
for _, other := range []netip.AddrPort{proxy, admin} {
if pages == other || api == other {
if other.IsValid() && (sameSocket(pages, other) || sameSocket(api, other)) {
return errors.New("consent listen and api_listen must differ from listen and admin_listen")
}
}
if pages == api {
if sameSocket(pages, api) {
return errors.New("consent listen and api_listen must differ")
}
if k.public, err = upstreamURL("consent public_url", k.PublicURL); err != nil {
Expand All @@ -581,12 +626,10 @@ func (k *Consent) validate(proxy netip.AddrPort, adminListen string) error {
k.public.Host = "[" + k.public.Host + "]"
}
}
if len(k.APIAllowSources) == 0 {
return errors.New("consent api_allow_sources is empty")
}
if k.apiSources, err = parseSources("consent api_allow_sources", k.APIAllowSources); err != nil {
if k.apiSources, err = c.sourcesFor("consent api_allow_sources", k.APIAllowSources); err != nil {
return err
}
k.anySource = c.ContainerMode
if !tokenHash.MatchString(k.APITokenSHA256) {
return errors.New("consent api_token_sha256 must be 64 lowercase hex characters")
}
Expand Down Expand Up @@ -745,6 +788,9 @@ func (s Service) BaseURL() *url.URL {

// SourceAllowed reports whether a connection from addr is accepted.
func (c *Config) SourceAllowed(addr netip.Addr) bool {
if c.ContainerMode {
return true
}
addr = addr.Unmap()
for _, p := range c.sources {
if p.Contains(addr) {
Expand Down
58 changes: 58 additions & 0 deletions internal/policy/policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,3 +319,61 @@ func TestValidateOAuth2SignIn(t *testing.T) {
t.Fatal("shared refresh token with different scopes accepted")
}
}

func TestContainerMode(t *testing.T) {
// A file cannot turn it on.
var fromFile Config
if err := json.Unmarshal([]byte(`{"ContainerMode":true,"container_mode":true}`), &fromFile); err != nil || fromFile.ContainerMode {
t.Fatalf("ContainerMode set from JSON: %v", err)
}

c := validConfig()
c.Listen, c.AllowSources = "0.0.0.0:8080", nil
if err := c.Validate(); err == nil {
t.Fatal("wildcard listen without container mode accepted")
}
c.ContainerMode = true
if err := c.Validate(); err != nil {
t.Fatalf("container mode: %v", err)
}
if !c.SourceAllowed(netip.MustParseAddr("203.0.113.7")) {
t.Fatal("container mode checked a source address")
}
c.AllowSources = []string{"10.0.0.1"}
if err := c.Validate(); err == nil {
t.Fatal("container mode with allow_sources accepted (they would prove nothing)")
}

consent := func() *Consent {
return &Consent{Listen: "0.0.0.0:8081", PublicURL: "https://connect.example.com/p/x", APIListen: "0.0.0.0:8082", APITokenSHA256: hash("api")}
}
cases := map[string]func(*Config){
"pages on the proxy port": func(c *Config) { c.Consent.Listen = "127.0.0.1:8080" },
"api on the proxy port": func(c *Config) { c.Consent.APIListen = "10.1.2.3:8080" },
"pages and api on one port": func(c *Config) { c.Consent.APIListen = "127.0.0.1:8081" },
"consent api sources in a box": func(c *Config) { c.Consent.APIAllowSources = []string{"10.0.0.1"} },
}
ok := validConfig()
ok.ContainerMode, ok.Listen, ok.AllowSources, ok.Consent = true, "0.0.0.0:8080", nil, consent()
if err := ok.Validate(); err != nil {
t.Fatalf("container consent: %v", err)
}
if !ok.Consent.APISourceAllowed(netip.MustParseAddr("203.0.113.7")) {
t.Fatal("container mode checked the consent API source")
}
for name, mutate := range cases {
c := validConfig()
c.ContainerMode, c.Listen, c.AllowSources, c.Consent = true, "0.0.0.0:8080", nil, consent()
mutate(c)
if err := c.Validate(); err == nil {
t.Errorf("%s: accepted", name)
}
}
// Outside container mode the consent listeners keep refusing wildcards.
f := validConfig()
f.Consent = consent()
f.Consent.APIAllowSources = []string{"10.0.0.5"}
if err := f.Validate(); err == nil {
t.Fatal("wildcard consent listen accepted outside container mode")
}
}
8 changes: 7 additions & 1 deletion internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// masked too in case a service mirrors something unexpected.
upstreamKey, alsoRedact = token, []string{refresh, secret}
}
out, err := buildOutbound(r.Context(), svc, upstreamKey, r.Method, rest, r.URL.RawQuery, outboundHeader(r.Header, svc), r.Body, r.ContentLength)
header := outboundHeader(r.Header, svc)
if snap.config.ContainerMode {
// The supervisor removes it; never pass the router's proof on even
// if one slipped through.
header.Del(policy.CallerHeader)
}
out, err := buildOutbound(r.Context(), svc, upstreamKey, r.Method, rest, r.URL.RawQuery, header, r.Body, r.ContentLength)
if errors.Is(err, errBadQuery) {
h.refund(roleName, service, access.DailyRequests, start)
fail(http.StatusBadRequest, "malformed query string")
Expand Down
Loading
Loading