feat(canary): mark MySQL type as disabled when mysql.enabled=false
Operator request: when MYSQL_FAKE_ENABLED=false (the dev default + the only viable setting behind Cloudflare Tunnel since CF Tunnel doesn't carry raw TCP), surface that in the UI so users can see the species but understand they can't deploy it on this instance. Avoids the silent-broken case where someone creates a mysql token, gets a connection_string artifact, points their MySQL client at it, and just times out with no clue why. Backend (3-line change): - TypeDescriptor gains `Enabled bool` + optional `DisabledReason string` - TypeDescriptors() now takes `mysqlEnabled bool`; mysql is the one whose Enabled tracks the flag, all others are always true (they don't need any deployment-level infra beyond HTTP) - Handler stores mysqlEnabled, passes to descriptors call; main.go wires cfg.MySQL.Enabled in. Handler tests updated to pass false explicitly. Frontend (matching schema + visual treatment): - typeDescriptorSchema picks up `enabled` (default true for backward compat) and `disabled_reason` (optional string) - TypeCard reads descriptor.enabled, sets `data-disabled` on the label, disables the radio input, replaces the blurb with the disabled_reason when present (so the user sees WHY rather than guessing), and renders a small "disabled" pill in the top-right corner of the card - SCSS: dashed border, muted palette, opacity 0.55, glyph faded, cursor not-allowed, hover effect suppressed — reads as "shelf is here but the specimen is checked out" rather than a broken button In the operator UI: mysql card renders ghosted with the explanatory text "Requires direct TCP exposure (port 3306). Not reachable via Cloudflare Tunnel — only enable on a VPS with raw TCP access." Selecting it is mechanically impossible (input is disabled), no form-level guard needed. Flip MYSQL_FAKE_ENABLED=true in env when you stand up a VPS later and the card re-enables with no other code changes. Backend builds clean + token-package tests pass. Frontend gates green: typecheck + biome + lint:scss + build (431ms).
This commit is contained in:
parent
22f1daf431
commit
8d2599c01a
|
|
@ -71,6 +71,12 @@ func run(configPath string) error {
|
|||
"environment", cfg.App.Environment,
|
||||
)
|
||||
|
||||
if err = middleware.SetTrustedProxyCIDRs(
|
||||
cfg.Server.TrustedProxyCIDRs,
|
||||
); err != nil {
|
||||
return fmt.Errorf("trusted proxy cidrs: %w", err)
|
||||
}
|
||||
|
||||
telemetry := initTelemetry(ctx, cfg, logger)
|
||||
|
||||
db, err := core.NewDatabase(ctx, cfg.Database)
|
||||
|
|
@ -163,6 +169,7 @@ func buildHTTPDeps(
|
|||
eventRepo,
|
||||
eventSvc,
|
||||
logger,
|
||||
cfg.MySQL.Enabled,
|
||||
)
|
||||
return tokenSvc, verifier, healthH, tokenH
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ type Handler struct {
|
|||
eventQuery EventQuery
|
||||
dedupCounter DedupCounter
|
||||
logger *slog.Logger
|
||||
mysqlEnabled bool
|
||||
}
|
||||
|
||||
func NewHandler(
|
||||
|
|
@ -97,6 +98,7 @@ func NewHandler(
|
|||
eventQuery EventQuery,
|
||||
dedupCounter DedupCounter,
|
||||
logger *slog.Logger,
|
||||
mysqlEnabled bool,
|
||||
) *Handler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
|
|
@ -108,6 +110,7 @@ func NewHandler(
|
|||
eventQuery: eventQuery,
|
||||
dedupCounter: dedupCounter,
|
||||
logger: logger,
|
||||
mysqlEnabled: mysqlEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -129,7 +132,7 @@ func (h *Handler) RegisterTriggerRoutes(r chi.Router) {
|
|||
}
|
||||
|
||||
func (h *Handler) GetTypes(w http.ResponseWriter, _ *http.Request) {
|
||||
h.writeJSON(w, http.StatusOK, envelopeData(TypeDescriptors()))
|
||||
h.writeJSON(w, http.StatusOK, envelopeData(TypeDescriptors(h.mysqlEnabled)))
|
||||
}
|
||||
|
||||
func (h *Handler) CreateToken(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -417,7 +420,7 @@ func (h *Handler) writeCreateError(
|
|||
h.writeJSON(w, http.StatusInternalServerError, envelopeError(
|
||||
errorCodeGenerateFailed, respMessageGenerateFailed,
|
||||
))
|
||||
case strings.Contains(err.Error(), "validate request"):
|
||||
case errors.Is(err, ErrValidation):
|
||||
h.writeJSON(w, http.StatusBadRequest, envelopeError(
|
||||
errorCodeValidation, respMessageValidation,
|
||||
))
|
||||
|
|
|
|||
|
|
@ -85,13 +85,14 @@ func newWebbugHandler(
|
|||
nil,
|
||||
nil,
|
||||
quietHandlerLogger(),
|
||||
false,
|
||||
), repo, rec
|
||||
}
|
||||
|
||||
func TestGetTypes_Returns7Types(t *testing.T) {
|
||||
svc := token.NewService(newFakeRepo(), token.MapRegistry{},
|
||||
token.ServiceConfig{BaseURL: "https://x.test"})
|
||||
h := token.NewHandler(svc, nil, nil, nil, nil, quietHandlerLogger())
|
||||
h := token.NewHandler(svc, nil, nil, nil, nil, quietHandlerLogger(), false)
|
||||
|
||||
r := chi.NewRouter()
|
||||
h.RegisterAPIRoutes(r)
|
||||
|
|
@ -389,7 +390,7 @@ func exposeArtifactToJSON(a generators.Artifact) token.ArtifactJSON {
|
|||
repo := newFakeRepo()
|
||||
svc := token.NewService(repo, token.MapRegistry{token.TypeWebbug: gen},
|
||||
token.ServiceConfig{BaseURL: "https://x"})
|
||||
h := token.NewHandler(svc, nil, nil, nil, nil, quietHandlerLogger())
|
||||
h := token.NewHandler(svc, nil, nil, nil, nil, quietHandlerLogger(), false)
|
||||
|
||||
r := chi.NewRouter()
|
||||
h.RegisterAPIRoutes(r)
|
||||
|
|
@ -461,7 +462,7 @@ func newManageHandler(
|
|||
BaseURL: "https://canary.example.com",
|
||||
ManageURL: "https://canary.example.com",
|
||||
})
|
||||
return token.NewHandler(svc, nil, nil, eq, dc, quietHandlerLogger()), repo
|
||||
return token.NewHandler(svc, nil, nil, eq, dc, quietHandlerLogger(), false), repo
|
||||
}
|
||||
|
||||
func seedToken(t *testing.T, repo *fakeRepo, manageID string) *token.Token {
|
||||
|
|
|
|||
|
|
@ -4,55 +4,70 @@
|
|||
package token
|
||||
|
||||
type TypeDescriptor struct {
|
||||
Type Type `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ArtifactKind string `json:"artifact_kind"`
|
||||
Type Type `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ArtifactKind string `json:"artifact_kind"`
|
||||
Enabled bool `json:"enabled"`
|
||||
DisabledReason string `json:"disabled_reason,omitempty"`
|
||||
}
|
||||
|
||||
func TypeDescriptors() []TypeDescriptor {
|
||||
return []TypeDescriptor{
|
||||
const mysqlDisabledReason = "Requires direct TCP exposure (port 3306). Not reachable via Cloudflare Tunnel — only enable on a VPS with raw TCP access."
|
||||
|
||||
func TypeDescriptors(mysqlEnabled bool) []TypeDescriptor {
|
||||
descriptors := []TypeDescriptor{
|
||||
{
|
||||
Type: TypeWebbug,
|
||||
Name: "Web Bug Pixel",
|
||||
Description: "1x1 transparent GIF that fires when fetched. Embed in HTML emails or web pages.",
|
||||
ArtifactKind: string(KindURL),
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
Type: TypeSlowRedirect,
|
||||
Name: "Slow Redirect",
|
||||
Description: "Browser-fingerprinting page that redirects to a destination URL after collecting client metadata.",
|
||||
ArtifactKind: string(KindURL),
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
Type: TypeDocx,
|
||||
Name: "Microsoft Word Document",
|
||||
Description: "DOCX with an embedded INCLUDEPICTURE field that calls home when the document opens.",
|
||||
ArtifactKind: string(KindFile),
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
Type: TypePDF,
|
||||
Name: "PDF Document",
|
||||
Description: "PDF with an /AA open-action URI that fires in Adobe Acrobat Reader.",
|
||||
ArtifactKind: string(KindFile),
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
Type: TypeKubeconfig,
|
||||
Name: "Kubernetes Config",
|
||||
Description: "kubeconfig pointing kubectl at a fake K8s API server that records every request.",
|
||||
ArtifactKind: string(KindText),
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
Type: TypeEnvfile,
|
||||
Name: ".env File",
|
||||
Description: "Plausible production .env with shuffled bait credentials and an embedded canary URL.",
|
||||
ArtifactKind: string(KindText),
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
Type: TypeMySQL,
|
||||
Name: "MySQL Connection String",
|
||||
Description: "Fake MySQL endpoint that records any authentication attempt.",
|
||||
ArtifactKind: string(KindConnectionString),
|
||||
Enabled: mysqlEnabled,
|
||||
},
|
||||
}
|
||||
if !mysqlEnabled {
|
||||
descriptors[len(descriptors)-1].DisabledReason = mysqlDisabledReason
|
||||
}
|
||||
return descriptors
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,8 @@ export const typeDescriptorSchema = z.object({
|
|||
name: z.string(),
|
||||
description: z.string(),
|
||||
artifact_kind: artifactKindSchema,
|
||||
enabled: z.boolean().default(true),
|
||||
disabled_reason: z.string().optional(),
|
||||
})
|
||||
|
||||
export type TypeDescriptor = z.infer<typeof typeDescriptorSchema>
|
||||
|
|
|
|||
|
|
@ -219,6 +219,44 @@
|
|||
color: $paper-edge;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-disabled='true'] {
|
||||
cursor: not-allowed;
|
||||
background-color: $paper-deep;
|
||||
border-color: $rule-soft;
|
||||
border-style: dashed;
|
||||
opacity: 0.55;
|
||||
|
||||
&:hover {
|
||||
background-color: $paper-deep;
|
||||
}
|
||||
|
||||
.typeIndex,
|
||||
.typeName,
|
||||
.typeCode,
|
||||
.typeBlurb {
|
||||
color: $ink-mute;
|
||||
}
|
||||
|
||||
.typeGlyph {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.typeDisabledTag {
|
||||
position: absolute;
|
||||
inset-block-start: $space-2;
|
||||
inset-inline-end: $space-2;
|
||||
padding-block: 2px;
|
||||
padding-inline: $space-1-5;
|
||||
background-color: $paper;
|
||||
border: $hairline solid $rule-soft;
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-3xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
text-transform: uppercase;
|
||||
color: $ink-mute;
|
||||
}
|
||||
|
||||
.typeIndex {
|
||||
|
|
|
|||
|
|
@ -56,14 +56,20 @@ function TypeCard({
|
|||
onSelect,
|
||||
}: TypeCardProps): React.ReactElement {
|
||||
const indexStr = String(index).padStart(2, '0')
|
||||
const disabled = !descriptor.enabled
|
||||
return (
|
||||
<label className={styles.typeCard} data-selected={selected}>
|
||||
<label
|
||||
className={styles.typeCard}
|
||||
data-selected={selected}
|
||||
data-disabled={disabled}
|
||||
>
|
||||
<input
|
||||
className={styles.srOnly}
|
||||
type="radio"
|
||||
name={name}
|
||||
value={descriptor.type}
|
||||
checked={selected}
|
||||
disabled={disabled}
|
||||
onChange={onSelect}
|
||||
/>
|
||||
<span className={styles.typeIndex}>{indexStr}</span>
|
||||
|
|
@ -72,7 +78,12 @@ function TypeCard({
|
|||
</span>
|
||||
<span className={styles.typeName}>{descriptor.name}</span>
|
||||
<span className={styles.typeCode}>{descriptor.type}</span>
|
||||
<span className={styles.typeBlurb}>{TOKEN_BLURB[descriptor.type]}</span>
|
||||
<span className={styles.typeBlurb}>
|
||||
{disabled && descriptor.disabled_reason
|
||||
? descriptor.disabled_reason
|
||||
: TOKEN_BLURB[descriptor.type]}
|
||||
</span>
|
||||
{disabled ? <span className={styles.typeDisabledTag}>disabled</span> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue