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
4 changes: 4 additions & 0 deletions .github/actions/deploy-prod/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,8 @@ runs:
steps.wait_prod_api_stability.outcome == 'success'
)
shell: bash
env:
HCLOUD_TOKEN: ${{ inputs.hcloud-token }}
run: |
go run ./scripts/gateway-receiver-binding restore-disabled --context admin@prod
go run ./scripts/gateway-receiver-binding apply-selected --context admin@prod
Expand All @@ -681,6 +683,8 @@ runs:
steps.publish_platform_manifest.outcome == 'success' &&
steps.wait_flux_revision.outcome != 'success'
shell: bash
env:
HCLOUD_TOKEN: ${{ inputs.hcloud-token }}
run: go run ./scripts/gateway-receiver-binding restore-disabled --context admin@prod

- name: Verify the deployed data product UI release
Expand Down
19 changes: 19 additions & 0 deletions docs/operations/gateway-receiver-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,25 @@ actual Service and Gateway identities. It requires both unbound policy specs to
match reviewed source and the validating policy's current generation to have
completed type checking without warnings.

Receiver operations also require a read-only observation of the load balancer's
effective disabled transport. The Service annotation and `ipMode` are controller
intent, so they do not establish that the provider has applied the setting.
Using the existing protected deployment credential, the helper discovers the
unique load balancer labelled for the bound Service UID and reads it again by
its returned identifier. Both services must explicitly report PROXY disabled,
with the bound public/private endpoints, destination NodePorts and private server
targets. Missing, partial, changed or unreadable facts fail before a receiver
operation. A second provider observation brackets a successful operation and
must retain its identity. These are bounded observations, not an atomic lock
against independent provider changes.

The helper sends only GET requests to the fixed provider API origin, refuses
redirects and incomplete inventory, and prints categorical errors. Neither
credentials nor provider response content enter its output. Selected-fence
installation, unselected restoration and deployed public verification use this
same gate. This adds no transport activation or asynchronous recovery support;
an effective active setting still refuses the disabled baseline.

Before restoring disabled state, the helper checks the generated listener and
both bindings. An active PROXY listener, unknown owner, foreign binding or ambiguous configuration
refuses every planned write. The helper
Expand Down
13 changes: 11 additions & 2 deletions scripts/gateway-receiver-binding/binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import (
"regexp"
)

type binding struct{ CIDR, ServiceUID, GatewayUID, ServiceVersion, GatewayVersion, PublicIPv4 string }
type binding struct {
CIDR, ServiceUID, GatewayUID, ServiceVersion, GatewayVersion, PublicIPv4 string
HTTPNodePort, HTTPSNodePort int
}
type metadata struct {
Name, Namespace, UID, ResourceVersion string
Generation int64
Expand Down Expand Up @@ -142,12 +145,18 @@ func derive(service, gateway []byte) (binding, error) {
}
ports := map[int]bool{}
nodePorts := map[int]bool{}
var httpNodePort, httpsNodePort int
for _, p := range s.Spec.Ports {
if (p.Port != 80 && p.Port != 443) || p.Protocol != "TCP" || p.NodePort < 30000 || p.NodePort > 32767 || ports[p.Port] || nodePorts[p.NodePort] {
return refuse()
}
ports[p.Port] = true
nodePorts[p.NodePort] = true
if p.Port == 80 {
httpNodePort = p.NodePort
} else {
httpsNodePort = p.NodePort
}
}
if len(s.Status.LoadBalancer.Ingress) < 2 || len(s.Status.LoadBalancer.Ingress) > 3 {
return refuse()
Expand Down Expand Up @@ -176,5 +185,5 @@ func derive(service, gateway []byte) (binding, error) {
if !private.IsValid() || !public.IsValid() {
return refuse()
}
return binding{CIDR: netip.PrefixFrom(private, 32).String(), ServiceUID: s.Metadata.UID, GatewayUID: g.Metadata.UID, ServiceVersion: s.Metadata.ResourceVersion, GatewayVersion: g.Metadata.ResourceVersion, PublicIPv4: public.String()}, nil
return binding{CIDR: netip.PrefixFrom(private, 32).String(), ServiceUID: s.Metadata.UID, GatewayUID: g.Metadata.UID, ServiceVersion: s.Metadata.ResourceVersion, GatewayVersion: g.Metadata.ResourceVersion, PublicIPv4: public.String(), HTTPNodePort: httpNodePort, HTTPSNodePort: httpsNodePort}, nil
}
11 changes: 11 additions & 0 deletions scripts/gateway-receiver-binding/binding_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ func TestProviderBindingRequiresOnePrivateHostAndItsGatewayOwner(t *testing.T) {
})
}
}

func TestBindingRetainsServiceDestinationsForProviderReadback(t *testing.T) {
service := serviceFixture()
ports := service["spec"].(map[string]any)["ports"].([]any)
ports[0], ports[1] = ports[1], ports[0]
raw, _ := json.Marshal(service)
b, err := derive(raw, gatewayFixture())
if err != nil || b.HTTPNodePort != 31080 || b.HTTPSNodePort != 31443 {
t.Fatal("provider destinations were lost or associated with list order")
}
}
func TestGatewayBindingRefusesUnobservedControllerState(t *testing.T) {
raw, _ := json.Marshal(serviceFixture())
var g map[string]any
Expand Down
204 changes: 204 additions & 0 deletions scripts/gateway-receiver-binding/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
package main

import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/netip"
"net/url"
"os"
"strconv"
"strings"
"time"
)

type providerIdentity struct{ ID, NetworkID int64 }

var errProviderMode = errors.New("provider-mode")

func readProvider(ctx context.Context, b binding, wantProxy bool) (providerIdentity, error) {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
defer transport.CloseIdleConnections()
return observeProvider(ctx, &http.Client{Transport: transport}, os.Getenv("HCLOUD_TOKEN"), b, wantProxy)
}

type providerBalancer struct {
ID int64
Name string
Labels map[string]string
PublicNet struct {
Enabled *bool
IPv4 struct {
IP string
Blocked *bool
}
} `json:"public_net"`
PrivateNet []struct {
Network int64
IP string
} `json:"private_net"`
Services []struct {
Protocol string
ListenPort int `json:"listen_port"`
DestinationPort int `json:"destination_port"`
ProxyProtocol *bool `json:"proxyprotocol"`
}
Targets []struct {
Type string
Server *struct{ ID int64 }
UsePrivateIP *bool `json:"use_private_ip"`
IP any
LabelSelector any `json:"label_selector"`
Targets []json.RawMessage
}
}

// providerMatches binds effective provider configuration to the already verified
// Kubernetes identity. An absent boolean cannot prove that a mode is disabled.
func providerMatches(lb providerBalancer, b binding, wantProxy bool) (providerIdentity, error) {
bad := func() (providerIdentity, error) { return providerIdentity{}, errors.New("provider-state") }
if lb.ID <= 0 || lb.Name != "platform-gateway" || lb.Labels["hcloud-ccm/service-uid"] != b.ServiceUID || lb.PublicNet.Enabled == nil || !*lb.PublicNet.Enabled || lb.PublicNet.IPv4.IP != b.PublicIPv4 || lb.PublicNet.IPv4.Blocked == nil || *lb.PublicNet.IPv4.Blocked || len(lb.PrivateNet) != 1 || len(lb.Services) != 2 || len(lb.Targets) == 0 {
return bad()
}
private, err := netip.ParsePrefix(b.CIDR)
if err != nil || private.Bits() != 32 || !private.Addr().Is4() || !private.Addr().IsPrivate() || lb.PrivateNet[0].IP != private.Addr().String() || lb.PrivateNet[0].Network <= 0 {
return bad()
}
ports := map[int]bool{}
modeMismatch := false
for _, service := range lb.Services {
destination := b.HTTPNodePort
if service.ListenPort == 443 {
destination = b.HTTPSNodePort
}
if (service.ListenPort != 80 && service.ListenPort != 443) || ports[service.ListenPort] || service.Protocol != "tcp" || service.DestinationPort != destination || service.ProxyProtocol == nil {
return bad()
}
if *service.ProxyProtocol != wantProxy {
modeMismatch = true
}
ports[service.ListenPort] = true
}
servers := map[int64]bool{}
for _, target := range lb.Targets {
if target.Type != "server" || target.Server == nil || target.Server.ID <= 0 || servers[target.Server.ID] || target.UsePrivateIP == nil || !*target.UsePrivateIP || target.IP != nil || target.LabelSelector != nil || len(target.Targets) != 0 {
return bad()
}
servers[target.Server.ID] = true
}
if modeMismatch {
return providerIdentity{}, errProviderMode
}
return providerIdentity{ID: lb.ID, NetworkID: lb.PrivateNet[0].Network}, nil
}

// Provider schema keys use lowercase ASCII, digits and underscores; labels are
// the sole arbitrary-key map. An allowlist also rejects Unicode simple-fold
// aliases accepted by Go's struct decoder, rather than enumerating exceptions.
// Validate that spelling before Go's case-insensitive struct decoder can allow
// an alias to stand in for, or override, an authoritative mode field.
func providerKeys(v any) bool {
switch value := v.(type) {
case map[string]any:
for key, item := range value {
invalid := strings.IndexFunc(key, func(r rune) bool {
return (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '_'
})
if key == "" || invalid != -1 || (key != "labels" && !providerKeys(item)) {
return false
}
}
case []any:
for _, item := range value {
if !providerKeys(item) {
return false
}
}
}
return true
}

func providerJSON(raw []byte, target any) error {
var keys any
if strictJSON(raw, &keys) != nil || !providerKeys(keys) || json.Unmarshal(raw, target) != nil {
return errors.New("provider-document")
}
return nil
}

// observeProvider performs two bounded GETs to a fixed API origin. Discovery is
// scoped by Service UID, then re-read by the returned resource ID. It makes no
// provider changes and returns only categorical errors, never response bodies.
func observeProvider(ctx context.Context, client *http.Client, token string, b binding, wantProxy bool) (providerIdentity, error) {
if token == "" || strings.ContainsAny(token, " \r\n\t") || client == nil || !uuid.MatchString(b.ServiceUID) || b.HTTPNodePort < 30000 || b.HTTPNodePort > 32767 || b.HTTPSNodePort < 30000 || b.HTTPSNodePort > 32767 || b.HTTPNodePort == b.HTTPSNodePort {
return providerIdentity{}, errors.New("provider-input")
}
bounded := *client
bounded.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
if bounded.Timeout <= 0 || bounded.Timeout > 20*time.Second {
bounded.Timeout = 20 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
get := func(path string, query url.Values, target any) error {
u := url.URL{Scheme: "https", Host: "api.hetzner.cloud", Path: path, RawQuery: query.Encode()}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return errors.New("provider-request")
}
request.Header.Set("Authorization", "Bearer "+token)
response, err := bounded.Do(request)
if err != nil {
return errors.New("provider-transport")
}
raw, readErr := io.ReadAll(io.LimitReader(response.Body, (512<<10)+1))
closeErr := response.Body.Close()
if response.StatusCode != http.StatusOK {
return errors.New("provider-response")
}
if readErr != nil || closeErr != nil || providerJSON(raw, target) != nil {
return errors.New("provider-document")
}
return nil
}
var response struct {
Balancers []providerBalancer `json:"load_balancers"`
Meta struct {
Pagination struct {
Page *int `json:"page"`
PerPage *int `json:"per_page"`
LastPage *int `json:"last_page"`
TotalEntries *int `json:"total_entries"`
NextPage json.RawMessage `json:"next_page"`
} `json:"pagination"`
} `json:"meta"`
}
if err := get("/v1/load_balancers", url.Values{"label_selector": {"hcloud-ccm/service-uid=" + b.ServiceUID}, "page": {"1"}, "per_page": {"50"}}, &response); err != nil {
return providerIdentity{}, err
}
p := response.Meta.Pagination
// A unique filtered result fits one page. Any indication of another page,
// omitted pagination, or a different count is unverified, never truncated.
if p.Page == nil || *p.Page != 1 || p.PerPage == nil || *p.PerPage != 50 || p.LastPage == nil || *p.LastPage != 1 || p.TotalEntries == nil || *p.TotalEntries != 1 || !bytes.Equal(bytes.TrimSpace(p.NextPage), []byte("null")) || len(response.Balancers) != 1 {
return providerIdentity{}, errors.New("provider-inventory")
}
before, err := providerMatches(response.Balancers[0], b, wantProxy)
if err != nil {
return providerIdentity{}, err
}
var detail struct {
Balancer providerBalancer `json:"load_balancer"`
}
if err = get("/v1/load_balancers/"+strconv.FormatInt(before.ID, 10), nil, &detail); err != nil {
return providerIdentity{}, err
}
after, err := providerMatches(detail.Balancer, b, wantProxy)
if err != nil || before != after {
return providerIdentity{}, errors.New("provider-changed")
}
return after, nil
}
29 changes: 29 additions & 0 deletions scripts/gateway-receiver-binding/provider_guard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package main

import (
"context"
"errors"
)

// A Service annotation expresses desired state. Gate receiver operations on the
// provider's effective disabled state and retain the same provider identity.
func withProviderBaseline(ctx context.Context, b binding, observe func(context.Context, binding, bool) (providerIdentity, error), operation func() error) error {
if observe == nil || operation == nil {
return errors.New("provider-capability")
}
before, err := observe(ctx, b, false)
if err != nil {
return err
}
if err = operation(); err != nil {
return err
}
after, err := observe(ctx, b, false)
if err != nil {
return err
}
if after != before {
return errors.New("provider-changed")
}
return nil
}
56 changes: 56 additions & 0 deletions scripts/gateway-receiver-binding/provider_guard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package main

import (
"context"
"errors"
"testing"
)

func TestProviderBaselineGuardsTheOperationAndPinsTheObservation(t *testing.T) {
for _, scenario := range []string{"disabled", "missing-capability", "unknown-before", "active-before", "changed-after", "unknown-after", "operation-failed"} {
t.Run(scenario, func(t *testing.T) {
_, b := providerFixture()
reads, operations := 0, 0
reader := func(_ context.Context, actual binding, wantProxy bool) (providerIdentity, error) {
reads++
if actual != b || wantProxy {
t.Fatal("operation was not bound to the disabled provider profile")
}
if (scenario == "unknown-before" && reads == 1) || (scenario == "unknown-after" && reads == 2) {
return providerIdentity{}, errors.New("provider-document")
}
if scenario == "active-before" && reads == 1 {
return providerIdentity{}, errProviderMode
}
id := int64(7)
if scenario == "changed-after" && reads == 2 {
id = 8
}
return providerIdentity{ID: id, NetworkID: 9}, nil
}
if scenario == "missing-capability" {
reader = nil
}
err := withProviderBaseline(context.Background(), b, reader, func() error {
operations++
if scenario == "operation-failed" {
return errors.New("source-admission-policy")
}
return nil
})
wantOperations := 1
if scenario == "missing-capability" || scenario == "unknown-before" || scenario == "active-before" {
wantOperations = 0
}
if operations != wantOperations || (err == nil) != (scenario == "disabled") {
t.Fatalf("provider disagreement or missing proof permitted an operation or success: operations=%d error=%v", operations, err)
}
if scenario == "disabled" && reads != 2 {
t.Fatal("operation lacked provider readback on both sides")
}
if scenario == "operation-failed" && (err == nil || err.Error() != "source-admission-policy") {
t.Fatal("a successful provider observation hid the operation failure")
}
})
}
}
Loading
Loading