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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ re-approves a fingerprint without repeating its label silently blanks it,
losing the annotation that makes the row identifiable. `UpsertStatus` does take
both, because the control plane is authoritative for both.

## Scoped approvals

See [scoped approval integration](docs/scoped-approvals.md) before enabling
client-CIDR restrictions. Gatekit validates and persists restrictions; each gate
remains responsible for enforcing them before advertising protocol support.

## Migrating an existing gate database

sshgate and tlsgate both have databases in service, with protocol fields in
Expand Down
90 changes: 90 additions & 0 deletions approval/scope.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Package approval validates client-address restrictions on fingerprint approvals.
package approval

import (
"encoding/json"
"fmt"
"net/netip"
"sort"
)

const Capability = "approval_ranges_v1"
const MaxRanges = 128

// Scope is an immutable set of client CIDRs. A nil *Scope means unrestricted.
// Its zero value is invalid, preventing an explicit empty scope from broadening trust.
type Scope struct{ prefixes []netip.Prefix }

func New(ranges []string) (*Scope, error) {
if len(ranges) == 0 || len(ranges) > MaxRanges {
return nil, fmt.Errorf("approval_ranges requires 1..%d CIDRs", MaxRanges)
}
seen := make(map[netip.Prefix]bool)
for _, raw := range ranges {
p, err := netip.ParsePrefix(raw)
if err != nil || p.Addr().Is4In6() {
return nil, fmt.Errorf("invalid approval CIDR %q", raw)
}
seen[p.Masked()] = true
}
s := &Scope{}
for p := range seen {
s.prefixes = append(s.prefixes, p)
}
sort.Slice(s.prefixes, func(i, j int) bool { return s.prefixes[i].String() < s.prefixes[j].String() })
return s, nil
}

func (s *Scope) Validate() error {
if s != nil && (len(s.prefixes) == 0 || len(s.prefixes) > MaxRanges) {
return fmt.Errorf("invalid empty or oversized approval scope")
}
return nil
}

func (s *Scope) Ranges() []string {
if s == nil {
return nil
}
out := make([]string, len(s.prefixes))
for i, p := range s.prefixes {
out[i] = p.String()
}
return out
}

// Allows uses the transport peer's address, not a hostname or untrusted header.
func (s *Scope) Allows(addr netip.Addr) bool {
if s == nil {
return true
}
if !addr.IsValid() || addr.Zone() != "" {
return false
}
addr = addr.Unmap()
for _, p := range s.prefixes {
if p.Contains(addr) {
return true
}
}
return false
}

func (s *Scope) MarshalJSON() ([]byte, error) {
if err := s.Validate(); err != nil {
return nil, err
}
return json.Marshal(s.Ranges())
}
func (s *Scope) UnmarshalJSON(data []byte) error {
var ranges []string
if err := json.Unmarshal(data, &ranges); err != nil {
return err
}
parsed, err := New(ranges)
if err != nil {
return err
}
*s = *parsed
return nil
}
38 changes: 38 additions & 0 deletions approval/scope_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package approval

import (
"encoding/json"
"net/netip"
"testing"
)

func TestScopeValidationAndMatching(t *testing.T) {
for _, raw := range []string{`[]`, `["bad"]`, `["::ffff:192.0.2.0/120"]`, `["fe80::1%eth0/64"]`, `[123]`} {
var s *Scope
if err := json.Unmarshal([]byte(raw), &s); err == nil {
t.Fatalf("accepted %s", raw)
}
}
s, err := New([]string{"192.0.2.9/24", "2001:db8:1::1/64", "192.0.2.0/24"})
if err != nil {
t.Fatal(err)
}
if len(s.Ranges()) != 2 {
t.Fatal(s.Ranges())
}
for ip, want := range map[string]bool{"192.0.2.25": true, "::ffff:192.0.2.25": true, "192.0.3.25": false, "2001:db8:1::5": true, "2001:db8:2::5": false, "fe80::1%eth0": false} {
if got := s.Allows(netip.MustParseAddr(ip)); got != want {
t.Errorf("%s: %t", ip, got)
}
}
if s.Allows(netip.Addr{}) {
t.Fatal("invalid address allowed")
}
var unrestricted *Scope
if !unrestricted.Allows(netip.MustParseAddr("198.51.100.1")) {
t.Fatal("legacy scope changed")
}
if _, err := json.Marshal(&Scope{}); err == nil {
t.Fatal("empty scope marshaled")
}
}
104 changes: 57 additions & 47 deletions controlplane/controlplane.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package controlplane
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/json"
Expand All @@ -17,6 +18,7 @@ import (
"strings"
"time"

"github.com/kilo666mj/gatekit/approval"
"github.com/kilo666mj/gatekit/store"
)

Expand All @@ -31,14 +33,16 @@ const (

// Config is the control_plane block of a gate's config file.
type Config struct {
URL string `json:"url"`
InstanceID string `json:"instance_id"`
Token string `json:"token"`
ClientCert string `json:"client_cert"`
ClientKey string `json:"client_key"`
CA string `json:"ca"`
ServerName string `json:"server_name"`
SyncInterval string `json:"sync_interval"`
// SupportsApprovalRanges must only be set by gates that enforce scoped approvals.
SupportsApprovalRanges bool `json:"-"`
URL string `json:"url"`
InstanceID string `json:"instance_id"`
Token string `json:"token"`
ClientCert string `json:"client_cert"`
ClientKey string `json:"client_key"`
CA string `json:"ca"`
ServerName string `json:"server_name"`
SyncInterval string `json:"sync_interval"`
// ApplyTrustedRanges atomically replaces control-plane managed source
// bypasses. It is runtime wiring, not serialized configuration.
ApplyTrustedRanges func([]string) error `json:"-"`
Expand Down Expand Up @@ -83,16 +87,17 @@ func (cfg Config) Interval() time.Duration {
}

type observation struct {
Fingerprint string `json:"fingerprint"`
Status store.Status `json:"status"`
Label string `json:"label,omitempty"`
FirstSeen string `json:"first_seen,omitempty"`
LastSeen string `json:"last_seen,omitempty"`
IPs []string `json:"ips,omitempty"`
Ports []int `json:"ports,omitempty"`
Sightings []store.Sighting `json:"sightings,omitempty"`
Count int `json:"count,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
ApprovalRanges *approval.Scope `json:"approval_ranges,omitempty"`
Fingerprint string `json:"fingerprint"`
Status store.Status `json:"status"`
Label string `json:"label,omitempty"`
FirstSeen string `json:"first_seen,omitempty"`
LastSeen string `json:"last_seen,omitempty"`
IPs []string `json:"ips,omitempty"`
Ports []int `json:"ports,omitempty"`
Sightings []store.Sighting `json:"sightings,omitempty"`
Count int `json:"count,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}

type observationBatch struct {
Expand All @@ -106,19 +111,15 @@ type policyResponse struct {
TrustedRanges *[]string `json:"trusted_ranges,omitempty"`
}

type decision struct {
Fingerprint string `json:"fingerprint"`
Status store.Status `json:"status"`
Label string `json:"label,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type decision = store.Decision

// Syncer pushes observations to gatehub and applies returned policy.
type Syncer struct {
store *store.Store
cfg Config
client *http.Client
cursor string
store *store.Store
cfg Config
client *http.Client
cursor string
cursorKey string
}

// New validates configuration and builds a Syncer.
Expand All @@ -130,7 +131,12 @@ func New(st *store.Store, cfg Config) (*Syncer, error) {
if err != nil {
return nil, err
}
return &Syncer{store: st, cfg: cfg, client: client}, nil
cursorKey := fmt.Sprintf("gatehub_cursor:%x", sha256.Sum256([]byte(cfg.URL+"\x00"+cfg.InstanceID)))
cursor, err := st.GetMeta(cursorKey)
if err != nil {
return nil, err
}
return &Syncer{store: st, cfg: cfg, client: client, cursor: cursor, cursorKey: cursorKey}, nil
}

// Start begins syncing in the background until ctx is cancelled. It is a no-op
Expand Down Expand Up @@ -273,31 +279,34 @@ func (s *Syncer) pullPolicy(ctx context.Context) (err error) {
return err
}
for _, d := range policy.Decisions {
if d.Fingerprint == "" {
continue
if err := d.Validate(); err != nil {
return fmt.Errorf("invalid policy: %w", err)
}
if !d.Status.Valid() {
log.Printf("gatehub policy ignored invalid status %q for %s", d.Status, d.Fingerprint)
continue
}
if err := s.store.UpsertStatus(d.Fingerprint, d.Status, d.Label); err != nil {
return fmt.Errorf("apply decision for %s: %w", d.Fingerprint, err)
if d.ApprovalRanges != nil && !s.cfg.SupportsApprovalRanges {
return fmt.Errorf("gate does not enforce approval_ranges")
}
}

// A pointer distinguishes an older Gatehub that omitted the field from a
// current Gatehub intentionally publishing an empty trusted set.
if policy.TrustedRanges != nil && s.cfg.ApplyTrustedRanges != nil {
if err := s.cfg.ApplyTrustedRanges(*policy.TrustedRanges); err != nil {
return fmt.Errorf("apply trusted ranges: %w", err)
}
}
if err := s.store.ApplyDecisions(policy.Decisions, s.cursorKey, policy.Cursor); err != nil {
return fmt.Errorf("apply policy: %w", err)
}
if policy.Cursor != "" {
s.cursor = policy.Cursor
}
return nil
}

func (s *Syncer) setAuth(req *http.Request) {
if s.cfg.SupportsApprovalRanges {
req.Header.Set("X-Gatekit-Capabilities", approval.Capability)
}
if s.cfg.Token != "" {
req.Header.Set("Authorization", "Bearer "+s.cfg.Token)
}
Expand All @@ -308,16 +317,17 @@ func (s *Syncer) setAuth(req *http.Request) {
// which is what lets one syncer serve every gate.
func toObservation(fp string, entry store.Entry) observation {
return observation{
Fingerprint: fp,
Status: entry.Status,
Label: entry.Label,
FirstSeen: entry.FirstSeen.UTC().Format(time.RFC3339Nano),
LastSeen: entry.LastSeen.UTC().Format(time.RFC3339Nano),
IPs: limited(entry.IPs, maxObservationValues),
Ports: limited(entry.Ports, maxObservationValues),
Sightings: limited(entry.Sightings, maxObservationValues),
Count: entry.Count,
Metadata: entry.Meta,
Fingerprint: fp,
ApprovalRanges: entry.ApprovalRanges,
Status: entry.Status,
Label: entry.Label,
FirstSeen: entry.FirstSeen.UTC().Format(time.RFC3339Nano),
LastSeen: entry.LastSeen.UTC().Format(time.RFC3339Nano),
IPs: limited(entry.IPs, maxObservationValues),
Ports: limited(entry.Ports, maxObservationValues),
Sightings: limited(entry.Sightings, maxObservationValues),
Count: entry.Count,
Metadata: entry.Meta,
}
}

Expand Down
6 changes: 0 additions & 6 deletions controlplane/controlplane_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,6 @@ func TestPullPolicyAppliesDecisions(t *testing.T) {
Decisions: []decision{
{Fingerprint: "known", Status: store.StatusBlocked, Label: "bad"},
{Fingerprint: "unseen", Status: store.StatusApproved, Label: "preapproved"},
{Fingerprint: "", Status: store.StatusApproved},
{Fingerprint: "junk", Status: store.Status("nonsense")},
},
}); err != nil {
t.Errorf("encode policy response: %v", err)
Expand Down Expand Up @@ -205,10 +203,6 @@ func TestPullPolicyAppliesDecisions(t *testing.T) {
if unseen.Status != store.StatusApproved {
t.Errorf("unseen = %+v", unseen)
}
// An unparseable status is skipped, not applied and not fatal.
if _, err := st.Get("junk"); err == nil {
t.Error("invalid status was applied")
}

if err := s.PullPolicy(); err != nil {
t.Fatalf("second PullPolicy: %v", err)
Expand Down
Loading
Loading