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:
CarterPerez-dev 2026-05-17 22:45:53 -04:00
parent 22f1daf431
commit 8d2599c01a
7 changed files with 90 additions and 13 deletions

View File

@ -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
}

View File

@ -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,
))

View File

@ -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 {

View File

@ -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
}

View File

@ -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>

View File

@ -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 {

View File

@ -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>
)
}