mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
feat(cli): port provisioning hardening to v1.0
This commit is contained in:
1 parent
e109c9be33
commit
3cd20a3eef
26 files changed
+3706
-672
No files matched your search
@@ -38,6 +38,16 @@ const (
|
||||
sendLogTimeout = 300 * time.Second
|
||||
)
|
||||
|
||||
// Provisioning-token rejection reasons the API sends in error.metadata.reason
|
||||
// (HTTP 400, code 40003). This list can grow; a value outside it is not an
|
||||
// error, just one cmd/cli does not classify yet.
|
||||
const (
|
||||
ReasonTokenInvalid = "token_invalid"
|
||||
ReasonTokenExpired = "token_expired"
|
||||
ReasonTokenLimitReached = "token_limit_reached"
|
||||
ReasonTokenDisabled = "token_disabled"
|
||||
)
|
||||
|
||||
// ResolverConfig represents Control D resolver data.
|
||||
type ResolverConfig struct {
|
||||
DOH string `json:"doh"`
|
||||
@@ -58,10 +68,41 @@ type utilityResponse struct {
|
||||
} `json:"body"`
|
||||
}
|
||||
|
||||
// errorMetadata carries additive, optional detail on top of Code/Message.
|
||||
// Older API deployments omit it, so it must decode to its zero value rather
|
||||
// than fail the whole response. Its custom UnmarshalJSON gives the same
|
||||
// tolerance to a malformed value: a metadata that is not an object, or a
|
||||
// Reason that is not a string (a number, an object, or null), degrades to
|
||||
// the zero value rather than failing the response that contains it.
|
||||
type errorMetadata struct {
|
||||
// Reason is a machine-readable rejection reason sent on provisioning-token
|
||||
// errors (HTTP 400, code 40003): token_invalid, token_expired,
|
||||
// token_limit_reached, or token_disabled. Empty when absent or malformed;
|
||||
// callers must treat any other value as unknown rather than reject the
|
||||
// response.
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func (m *errorMetadata) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
Reason json.RawMessage `json:"reason"`
|
||||
}
|
||||
// Best-effort: a metadata that is not an object, or a reason that is not
|
||||
// a string (number, object, null), leaves the zero value instead of
|
||||
// failing this decode. Code and Message still classify the failure.
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
*m = errorMetadata{}
|
||||
return nil
|
||||
}
|
||||
_ = json.Unmarshal(raw.Reason, &m.Reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
ErrorField struct {
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
Metadata errorMetadata `json:"metadata"`
|
||||
} `json:"error"`
|
||||
// StatusCode is the HTTP status the API answered with. It is not part of the JSON
|
||||
// body: this type is built for *any* non-200 whose body decodes, so the body alone
|
||||
|
||||
@@ -96,3 +96,113 @@ func TestAPIErrorRecordsHTTPStatus(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAPIErrorDecodesRejectionReason pins the additive metadata.reason field the
|
||||
// API sends on provisioning-token rejections. cmd/cli maps known reasons to their
|
||||
// own failure codes, and must fall back cleanly when the field is absent or holds
|
||||
// a value this build does not recognize yet.
|
||||
func TestAPIErrorDecodesRejectionReason(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantReason string
|
||||
}{
|
||||
{
|
||||
name: "known reason",
|
||||
body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":"token_disabled"}}}`,
|
||||
wantReason: "token_disabled",
|
||||
},
|
||||
{
|
||||
name: "reason absent",
|
||||
body: `{"error":{"message":"invalid token","code":40003}}`,
|
||||
wantReason: "",
|
||||
},
|
||||
{
|
||||
name: "unknown reason value",
|
||||
body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":"something_new"}}}`,
|
||||
wantReason: "something_new",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
d := json.NewDecoder(strings.NewReader(tc.body))
|
||||
errResp, err := apiErrorFromResponse(http.StatusBadRequest, d)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected decode error: %v", err)
|
||||
}
|
||||
if errResp.ErrorField.Metadata.Reason != tc.wantReason {
|
||||
t.Errorf("reason = %q, want %q", errResp.ErrorField.Metadata.Reason, tc.wantReason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIErrorToleratesMalformedRejectionReason pins the fix for a decode error
|
||||
// confined to metadata.reason: a reason sent as the wrong JSON type must not
|
||||
// discard the rest of the response. Before this fix, apiErrorFromResponse
|
||||
// returned the raw decode error and nothing else, which cmd/cli's
|
||||
// apiFailureCode cannot recognize as an *ErrorResponse - it falls back to
|
||||
// API_UNREACHABLE (a retryable bootstrap failure) instead of the permanent
|
||||
// rejection the HTTP status and code actually describe.
|
||||
func TestAPIErrorToleratesMalformedRejectionReason(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "reason as a number", body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":12345}}}`},
|
||||
{name: "reason as an object", body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":{"inner":"value"}}}}`},
|
||||
{name: "reason as null", body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":null}}}`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
d := json.NewDecoder(strings.NewReader(tc.body))
|
||||
errResp, err := apiErrorFromResponse(http.StatusBadRequest, d)
|
||||
if err != nil {
|
||||
t.Fatalf("a malformed reason must not fail the whole decode: %v", err)
|
||||
}
|
||||
if errResp.ErrorField.Message != "invalid token" {
|
||||
t.Errorf("message = %q, want it to survive the malformed reason", errResp.ErrorField.Message)
|
||||
}
|
||||
if errResp.ErrorField.Code != 40003 {
|
||||
t.Errorf("code = %d, want it to survive the malformed reason", errResp.ErrorField.Code)
|
||||
}
|
||||
if errResp.ErrorField.Metadata.Reason != "" {
|
||||
t.Errorf("reason = %q, want empty for a malformed value", errResp.ErrorField.Metadata.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIErrorToleratesMalformedMetadata pins the same tolerance one level
|
||||
// up: a metadata field that is not a JSON object must not discard the rest
|
||||
// of the response. Code and Message still classify the failure, and Reason
|
||||
// stays empty.
|
||||
func TestAPIErrorToleratesMalformedMetadata(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "metadata as a string", body: `{"error":{"message":"invalid token","code":40003,"metadata":"foo"}}`},
|
||||
{name: "metadata as a number", body: `{"error":{"message":"invalid token","code":40003,"metadata":7}}`},
|
||||
{name: "metadata as an array", body: `{"error":{"message":"invalid token","code":40003,"metadata":["reason"]}}`},
|
||||
{name: "metadata as a boolean", body: `{"error":{"message":"invalid token","code":40003,"metadata":true}}`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
d := json.NewDecoder(strings.NewReader(tc.body))
|
||||
errResp, err := apiErrorFromResponse(http.StatusBadRequest, d)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected decode error: %v", err)
|
||||
}
|
||||
if errResp.ErrorField.Code != 40003 {
|
||||
t.Errorf("code = %d, want 40003", errResp.ErrorField.Code)
|
||||
}
|
||||
if errResp.ErrorField.Message != "invalid token" {
|
||||
t.Errorf("message = %q, want %q", errResp.ErrorField.Message, "invalid token")
|
||||
}
|
||||
if errResp.ErrorField.Metadata.Reason != "" {
|
||||
t.Errorf("reason = %q, want empty for a malformed metadata", errResp.ErrorField.Metadata.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package controld
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ProbeReachability makes one lightweight request to the ControlD API host,
|
||||
// reusing the same transport and IP-fallback logic real provisioning traffic
|
||||
// takes. Any HTTP response, even an error status, counts as reachable: this
|
||||
// checks the network path, not whether the endpoint accepts the request.
|
||||
//
|
||||
// The caller controls how long to wait via ctx; there is no timeout here
|
||||
// beyond what ctx enforces.
|
||||
func ProbeReachability(ctx context.Context, cdDev bool) error {
|
||||
apiURL := apiURLCom
|
||||
if cdDev {
|
||||
apiURL = apiURLDev
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := &http.Client{Transport: apiTransport(cdDev)}
|
||||
resp, err := doWithFallback(client, req, apiServerIP(cdDev))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package controld
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProbeReachabilityRespectsCancelledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := ProbeReachability(ctx, false); err == nil {
|
||||
t.Error("expected an error for an already-cancelled context")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user