|
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "encoding/json" |
| 6 | + "errors" |
| 7 | + "fmt" |
| 8 | + "maps" |
| 9 | + |
| 10 | + "github.com/openctl/openctl/pkg/pluginproto" |
| 11 | + "github.com/openctl/openctl/pkg/protocol" |
| 12 | +) |
| 13 | + |
| 14 | +// kindTunnelRoute is the "expose one app" primitive (G1). A Tunnel's ingress is |
| 15 | +// a single ordered list, so managing it from N per-app resources would clobber |
| 16 | +// (last-writer-wins). TunnelRoute instead contributes ONE host-scoped rule to a |
| 17 | +// named Tunnel via a read-modify-write keyed by hostname, so many apps coexist |
| 18 | +// on one tunnel — each owns its own resource. Pair it with a CNAME DNSRecord |
| 19 | +// that $refs the Tunnel's status.cnameTarget to finish the public wiring. |
| 20 | +const kindTunnelRoute = "TunnelRoute" |
| 21 | + |
| 22 | +// tunnelRouteState records what a route owns so Delete can pull exactly its |
| 23 | +// rule (and Get can probe for it) without re-reading the spec. |
| 24 | +type tunnelRouteState struct { |
| 25 | + AccountID string `json:"accountId"` |
| 26 | + TunnelID string `json:"tunnelId"` |
| 27 | + TunnelName string `json:"tunnelName"` |
| 28 | + Hostname string `json:"hostname"` |
| 29 | +} |
| 30 | + |
| 31 | +// cfTunnelConfig is a tunnel's ingress configuration as Cloudflare returns it |
| 32 | +// from GET .../configurations. |
| 33 | +type cfTunnelConfig struct { |
| 34 | + Config struct { |
| 35 | + Ingress []map[string]any `json:"ingress"` |
| 36 | + } `json:"config"` |
| 37 | +} |
| 38 | + |
| 39 | +// mergeIngressRule upserts a host-scoped rule into a tunnel's ingress list, |
| 40 | +// keyed by hostname, and guarantees exactly one catch-all (http_status:404) as |
| 41 | +// the final rule (Cloudflare rejects a config whose last rule is host-scoped). |
| 42 | +// Rules for OTHER hostnames are preserved, so many TunnelRoutes coexist on one |
| 43 | +// tunnel without clobbering each other. Pure/deterministic. |
| 44 | +func mergeIngressRule(existing []map[string]any, hostname, service, path string) []map[string]any { |
| 45 | + out := make([]map[string]any, 0, len(existing)+2) |
| 46 | + for _, r := range existing { |
| 47 | + h, _ := r["hostname"].(string) |
| 48 | + if h == "" || h == hostname { |
| 49 | + continue // drop the catch-all and any prior rule for this hostname |
| 50 | + } |
| 51 | + out = append(out, r) |
| 52 | + } |
| 53 | + rule := map[string]any{"hostname": hostname, "service": service} |
| 54 | + if path != "" { |
| 55 | + rule["path"] = path |
| 56 | + } |
| 57 | + out = append(out, rule) |
| 58 | + out = append(out, map[string]any{"service": "http_status:404"}) |
| 59 | + return out |
| 60 | +} |
| 61 | + |
| 62 | +// removeIngressRule drops the rule for hostname (and any catch-all) and |
| 63 | +// re-appends a single trailing catch-all. Idempotent — removing an absent |
| 64 | +// hostname just re-normalizes the catch-all. |
| 65 | +func removeIngressRule(existing []map[string]any, hostname string) []map[string]any { |
| 66 | + out := make([]map[string]any, 0, len(existing)+1) |
| 67 | + for _, r := range existing { |
| 68 | + h, _ := r["hostname"].(string) |
| 69 | + if h == "" || h == hostname { |
| 70 | + continue |
| 71 | + } |
| 72 | + out = append(out, r) |
| 73 | + } |
| 74 | + out = append(out, map[string]any{"service": "http_status:404"}) |
| 75 | + return out |
| 76 | +} |
| 77 | + |
| 78 | +func (p *provider) readTunnelIngress(ctx context.Context, acct, tunnelID string) ([]map[string]any, error) { |
| 79 | + var cfg cfTunnelConfig |
| 80 | + err := p.client.do(ctx, "GET", "/accounts/"+acct+"/cfd_tunnel/"+tunnelID+"/configurations", nil, nil, &cfg) |
| 81 | + if err != nil { |
| 82 | + if errors.Is(err, errNotFound) { |
| 83 | + return nil, nil // no configuration pushed yet |
| 84 | + } |
| 85 | + return nil, err |
| 86 | + } |
| 87 | + return cfg.Config.Ingress, nil |
| 88 | +} |
| 89 | + |
| 90 | +func (p *provider) writeTunnelIngress(ctx context.Context, acct, tunnelID string, ingress []map[string]any) error { |
| 91 | + body := map[string]any{"config": map[string]any{"ingress": ingress}} |
| 92 | + return p.client.do(ctx, "PUT", "/accounts/"+acct+"/cfd_tunnel/"+tunnelID+"/configurations", nil, body, nil) |
| 93 | +} |
| 94 | + |
| 95 | +// applyTunnelRoute upserts this route's ingress rule into the named tunnel. |
| 96 | +// |
| 97 | +// Concurrency note: this is a read-modify-write on the tunnel's shared config. |
| 98 | +// Applies of the same resource are serialized, and routes are normally ordered |
| 99 | +// after the Tunnel via a $ref; two routes to the SAME tunnel applied |
| 100 | +// concurrently could race (last write wins). For a homelab that's acceptable; |
| 101 | +// apply routes to one tunnel serially if it matters. |
| 102 | +func (p *provider) applyTunnelRoute(ctx context.Context, m *protocol.Resource, _ json.RawMessage) (*pluginproto.ApplyResult, error) { |
| 103 | + acct := p.tunnelAccount(m.Spec) |
| 104 | + if acct == "" { |
| 105 | + return nil, fmt.Errorf("no account for TunnelRoute %q (set spec.accountId or provider defaults.accountId)", m.Metadata.Name) |
| 106 | + } |
| 107 | + tunnelName := specString(m.Spec, "tunnel") |
| 108 | + hostname := specString(m.Spec, "hostname") |
| 109 | + service := specString(m.Spec, "service") |
| 110 | + if tunnelName == "" || hostname == "" || service == "" { |
| 111 | + return nil, fmt.Errorf("TunnelRoute %q requires spec.tunnel, spec.hostname and spec.service", m.Metadata.Name) |
| 112 | + } |
| 113 | + path := specString(m.Spec, "path") |
| 114 | + |
| 115 | + tunnelID, err := p.findTunnelID(ctx, acct, tunnelName) |
| 116 | + if err != nil { |
| 117 | + return nil, err |
| 118 | + } |
| 119 | + ingress, err := p.readTunnelIngress(ctx, acct, tunnelID) |
| 120 | + if err != nil { |
| 121 | + return nil, err |
| 122 | + } |
| 123 | + if err := p.writeTunnelIngress(ctx, acct, tunnelID, mergeIngressRule(ingress, hostname, service, path)); err != nil { |
| 124 | + return nil, err |
| 125 | + } |
| 126 | + |
| 127 | + newState, _ := json.Marshal(tunnelRouteState{AccountID: acct, TunnelID: tunnelID, TunnelName: tunnelName, Hostname: hostname}) |
| 128 | + return &pluginproto.ApplyResult{Resource: tunnelRouteObserved(m, tunnelID), State: newState}, nil |
| 129 | +} |
| 130 | + |
| 131 | +func (p *provider) getTunnelRoute(ctx context.Context, name string, state json.RawMessage) (*pluginproto.GetResult, error) { |
| 132 | + var st tunnelRouteState |
| 133 | + if len(state) > 0 { |
| 134 | + _ = json.Unmarshal(state, &st) |
| 135 | + } |
| 136 | + if st.TunnelID == "" || st.AccountID == "" || st.Hostname == "" { |
| 137 | + return nil, pluginproto.NotFound(fmt.Sprintf("TunnelRoute %q has no prior state", name)) |
| 138 | + } |
| 139 | + ingress, err := p.readTunnelIngress(ctx, st.AccountID, st.TunnelID) |
| 140 | + if err != nil { |
| 141 | + if errors.Is(err, errNotFound) { |
| 142 | + return nil, pluginproto.NotFound(fmt.Sprintf("TunnelRoute %q: tunnel config gone", name)) |
| 143 | + } |
| 144 | + return nil, err |
| 145 | + } |
| 146 | + for _, r := range ingress { |
| 147 | + if h, _ := r["hostname"].(string); h == st.Hostname { |
| 148 | + res := &protocol.Resource{APIVersion: apiVersion, Kind: kindTunnelRoute, Status: map[string]any{ |
| 149 | + "tunnelId": st.TunnelID, "hostname": st.Hostname, "phase": "Ready", |
| 150 | + }} |
| 151 | + res.Metadata.Name = name |
| 152 | + return &pluginproto.GetResult{Resource: res, State: state}, nil |
| 153 | + } |
| 154 | + } |
| 155 | + return nil, pluginproto.NotFound(fmt.Sprintf("TunnelRoute %q: no rule for %s in tunnel %s", name, st.Hostname, st.TunnelName)) |
| 156 | +} |
| 157 | + |
| 158 | +func (p *provider) deleteTunnelRoute(ctx context.Context, state json.RawMessage) error { |
| 159 | + var st tunnelRouteState |
| 160 | + if len(state) > 0 { |
| 161 | + _ = json.Unmarshal(state, &st) |
| 162 | + } |
| 163 | + if st.TunnelID == "" || st.AccountID == "" || st.Hostname == "" { |
| 164 | + return nil |
| 165 | + } |
| 166 | + ingress, err := p.readTunnelIngress(ctx, st.AccountID, st.TunnelID) |
| 167 | + if err != nil { |
| 168 | + if errors.Is(err, errNotFound) { |
| 169 | + return nil // tunnel/config already gone |
| 170 | + } |
| 171 | + return err |
| 172 | + } |
| 173 | + return p.writeTunnelIngress(ctx, st.AccountID, st.TunnelID, removeIngressRule(ingress, st.Hostname)) |
| 174 | +} |
| 175 | + |
| 176 | +// tunnelRouteObserved echoes the desired spec plus a Ready status. |
| 177 | +func tunnelRouteObserved(m *protocol.Resource, tunnelID string) *protocol.Resource { |
| 178 | + spec := make(map[string]any, len(m.Spec)) |
| 179 | + maps.Copy(spec, m.Spec) |
| 180 | + r := &protocol.Resource{ |
| 181 | + APIVersion: apiVersion, |
| 182 | + Kind: kindTunnelRoute, |
| 183 | + Spec: spec, |
| 184 | + Status: map[string]any{ |
| 185 | + "tunnelId": tunnelID, |
| 186 | + "hostname": specString(m.Spec, "hostname"), |
| 187 | + "phase": "Ready", |
| 188 | + }, |
| 189 | + } |
| 190 | + r.Metadata.Name = m.Metadata.Name |
| 191 | + return r |
| 192 | +} |
0 commit comments