feat(canary): mysql generator + register in registry
Phase 8 third commit. Closes the generator set — all 7 token types are
now registered.
mysql/generator.go:
- Generator with publicHost / publicPort / database fields. New()
returns localhost:3306/internal_db defaults; NewWithAddress(host,
port) overrides for Phase 9 wire-up
- Generate writes mysql_username into t.Metadata (json.RawMessage
merge via setMySQLUsername helper — same defensive map[string]
json.RawMessage pattern as envfile's extractIncludeKeys: malformed
existing metadata is replaced rather than erroring, existing
fields preserved, prior mysql_username overwritten)
- Returns Artifact{Kind: KindConnectionString, ConnectionString:
"mysql://canary_<id>@<host>:<port>/internal_db"} per spec §9.7
lines 1345-1352. baseURL ignored (mysql connection strings don't
use it)
- Trigger(ctx, t, r) returns (nil, nil, ErrHTTPTriggerNotSupported)
sentinel error — mysql triggers fire over the TCP listener (see
server.go/handler.go), not via the HTTP router. Phase 9's router
will not mount any HTTP route to mysql.Trigger; this is defense
for accidental programmatic calls
- Note on spec §9.7 line 1343: `t.Metadata["mysql_username"] =
username` doesn't compile against the real Token.Metadata
(json.RawMessage). The setMySQLUsername helper is the sanctioned
*string-shape adaptation, matching the slowredirect/envfile
metadata patterns
mysql/generator_test.go (~11 cases):
- Type / KindConnectionString / default connection string format /
custom address / canary_ prefix invariant / metadata persistence
on empty token / preservation of existing metadata fields /
malformed-metadata fallback / stale-mysql_username overwrite /
baseURL ignored / Trigger returns ErrHTTPTriggerNotSupported with
nil event + nil response
registry update: cardinality 6 → 7; pending list now empty.
TestBuild_AllSevenGeneratorsRegistered replaces the
TypesPresentInPhaseN test pattern from prior phases, asserting all 7
generator types are present (closes the generator set; future phases
add services/handlers, not generators).
DEFERRED TO PHASE 9 (task 8.5): wiring `go mysql.Run(ctx, addr,
handler)` into cmd/canary/main.go. The handoff anti-relitigation set
documents "Phase 9 wires the registry into main.go" — the mysql
listener goroutine fits naturally into Phase 9's main.go work
alongside config.Config.MySQL.Enabled, config.Config.MySQL.Addr,
the token.Service (for TokenLookup), and the event.Service (for
EventRecorder). Wiring it in Phase 8 would require partial config
additions that Phase 9 then re-touches.
Pre-rollup acceptance gate clean at HEAD:
- go build ./... + go vet ./... clean
- go test -race -timeout=60s ./... all packages pass (~115 tests
added across Phase 8 alone)
- go test -tags=integration -race -timeout=300s pass
- golangci-lint run ./... → 0 issues.
- zero //nolint pragmas anywhere
This commit is contained in:
parent
4d4d4951ba
commit
fe80c94a73
|
|
@ -0,0 +1,103 @@
|
|||
// ©AngelaMos | 2026
|
||||
// generator.go
|
||||
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPublicHost = "localhost"
|
||||
defaultPublicPort = 3306
|
||||
defaultDatabase = "internal_db"
|
||||
|
||||
connectionStringFmt = "mysql://%s@%s:%d/%s"
|
||||
)
|
||||
|
||||
type Generator struct {
|
||||
publicHost string
|
||||
publicPort int
|
||||
database string
|
||||
}
|
||||
|
||||
func New() *Generator {
|
||||
return NewWithAddress(defaultPublicHost, defaultPublicPort)
|
||||
}
|
||||
|
||||
func NewWithAddress(host string, port int) *Generator {
|
||||
return &Generator{
|
||||
publicHost: host,
|
||||
publicPort: port,
|
||||
database: defaultDatabase,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Generator) Type() token.Type { return token.TypeMySQL }
|
||||
|
||||
func (g *Generator) Generate(
|
||||
_ context.Context,
|
||||
t *token.Token,
|
||||
_ string,
|
||||
) (generators.Artifact, error) {
|
||||
username := mysqlUsernamePrefix + t.ID
|
||||
|
||||
newMeta, err := setMySQLUsername(t.Metadata, username)
|
||||
if err != nil {
|
||||
return generators.Artifact{}, fmt.Errorf(
|
||||
"mysql: persist username: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
t.Metadata = newMeta
|
||||
|
||||
connStr := fmt.Sprintf(
|
||||
connectionStringFmt,
|
||||
username,
|
||||
g.publicHost,
|
||||
g.publicPort,
|
||||
g.database,
|
||||
)
|
||||
|
||||
return generators.Artifact{
|
||||
Kind: generators.KindConnectionString,
|
||||
ConnectionString: connStr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (g *Generator) Trigger(
|
||||
_ context.Context,
|
||||
_ *token.Token,
|
||||
_ *http.Request,
|
||||
) (*event.Event, *generators.TriggerResponse, error) {
|
||||
return nil, nil, ErrHTTPTriggerNotSupported
|
||||
}
|
||||
|
||||
func setMySQLUsername(
|
||||
metadata json.RawMessage,
|
||||
username string,
|
||||
) (json.RawMessage, error) {
|
||||
m := make(map[string]json.RawMessage)
|
||||
if len(metadata) > 0 {
|
||||
if err := json.Unmarshal(metadata, &m); err != nil {
|
||||
m = make(map[string]json.RawMessage)
|
||||
}
|
||||
}
|
||||
val, err := json.Marshal(username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal mysql_username: %w", err)
|
||||
}
|
||||
m[extraMySQLUsername] = val
|
||||
out, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal merged metadata: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
// ©AngelaMos | 2026
|
||||
// generator_test.go
|
||||
|
||||
package mysql_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/mysql"
|
||||
)
|
||||
|
||||
func newMySQLToken(id string) *token.Token {
|
||||
return &token.Token{
|
||||
ID: id,
|
||||
ManageID: "manage-" + id,
|
||||
Type: token.TypeMySQL,
|
||||
Memo: "unit test mysql",
|
||||
AlertChannel: token.ChannelWebhook,
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerator_TypeIsMySQL(t *testing.T) {
|
||||
require.Equal(t, token.TypeMySQL, mysql.New().Type())
|
||||
}
|
||||
|
||||
func TestGenerate_ArtifactKindIsConnectionString(t *testing.T) {
|
||||
g := mysql.New()
|
||||
art, err := g.Generate(context.Background(), newMySQLToken("abc"), "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, generators.KindConnectionString, art.Kind)
|
||||
}
|
||||
|
||||
func TestGenerate_ConnectionStringDefaultLocalhost(t *testing.T) {
|
||||
g := mysql.New()
|
||||
art, err := g.Generate(context.Background(), newMySQLToken("abc"), "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(
|
||||
t,
|
||||
"mysql://canary_abc@localhost:3306/internal_db",
|
||||
art.ConnectionString,
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerate_ConnectionStringCustomAddress(t *testing.T) {
|
||||
g := mysql.NewWithAddress("canary.example.com", 13306)
|
||||
art, err := g.Generate(context.Background(), newMySQLToken("xyz"), "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(
|
||||
t,
|
||||
"mysql://canary_xyz@canary.example.com:13306/internal_db",
|
||||
art.ConnectionString,
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerate_UsernamePrefixIsCanary_(t *testing.T) {
|
||||
g := mysql.New()
|
||||
art, err := g.Generate(context.Background(), newMySQLToken("probeid"), "")
|
||||
require.NoError(t, err)
|
||||
require.Contains(
|
||||
t,
|
||||
art.ConnectionString,
|
||||
"canary_probeid@",
|
||||
"username must be canary_+token.ID for TCP-side lookup parity",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerate_PersistsMySQLUsernameInMetadata(t *testing.T) {
|
||||
g := mysql.New()
|
||||
tok := newMySQLToken("abc")
|
||||
require.Empty(t, tok.Metadata, "fresh token has no metadata yet")
|
||||
|
||||
_, err := g.Generate(context.Background(), tok, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotEmpty(
|
||||
t,
|
||||
tok.Metadata,
|
||||
"Generate must persist mysql_username into token metadata",
|
||||
)
|
||||
|
||||
var m map[string]any
|
||||
require.NoError(t, json.Unmarshal(tok.Metadata, &m))
|
||||
require.Equal(t, "canary_abc", m["mysql_username"])
|
||||
}
|
||||
|
||||
func TestGenerate_PreservesExistingMetadataFields(t *testing.T) {
|
||||
g := mysql.New()
|
||||
tok := newMySQLToken("abc")
|
||||
tok.Metadata = json.RawMessage(`{"existing_field":"keep_me","other":42}`)
|
||||
|
||||
_, err := g.Generate(context.Background(), tok, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
var m map[string]any
|
||||
require.NoError(t, json.Unmarshal(tok.Metadata, &m))
|
||||
require.Equal(t, "keep_me", m["existing_field"])
|
||||
other, ok := m["other"].(float64)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 42, int(other))
|
||||
require.Equal(t, "canary_abc", m["mysql_username"])
|
||||
}
|
||||
|
||||
func TestGenerate_MalformedMetadataIsReplaced(t *testing.T) {
|
||||
g := mysql.New()
|
||||
tok := newMySQLToken("abc")
|
||||
tok.Metadata = json.RawMessage(`{not valid json`)
|
||||
|
||||
_, err := g.Generate(context.Background(), tok, "")
|
||||
require.NoError(
|
||||
t,
|
||||
err,
|
||||
"malformed metadata must not block generation — replace with a fresh map",
|
||||
)
|
||||
|
||||
var m map[string]any
|
||||
require.NoError(t, json.Unmarshal(tok.Metadata, &m))
|
||||
require.Equal(t, "canary_abc", m["mysql_username"])
|
||||
}
|
||||
|
||||
func TestGenerate_OverwritesPriorMySQLUsername(t *testing.T) {
|
||||
g := mysql.New()
|
||||
tok := newMySQLToken("abc")
|
||||
tok.Metadata = json.RawMessage(`{"mysql_username":"stale_value"}`)
|
||||
|
||||
_, err := g.Generate(context.Background(), tok, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
var m map[string]any
|
||||
require.NoError(t, json.Unmarshal(tok.Metadata, &m))
|
||||
require.Equal(
|
||||
t,
|
||||
"canary_abc",
|
||||
m["mysql_username"],
|
||||
"regeneration must overwrite stale mysql_username with the current token's",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerate_BaseURLIgnored(t *testing.T) {
|
||||
g := mysql.New()
|
||||
|
||||
art1, err := g.Generate(
|
||||
context.Background(),
|
||||
newMySQLToken("abc"),
|
||||
"https://canary.example.com",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
art2, err := g.Generate(
|
||||
context.Background(),
|
||||
newMySQLToken("abc"),
|
||||
"https://different.example.com/sub",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(
|
||||
t,
|
||||
art1.ConnectionString,
|
||||
art2.ConnectionString,
|
||||
"baseURL is irrelevant to mysql connection strings",
|
||||
)
|
||||
}
|
||||
|
||||
func TestTrigger_ReturnsHTTPNotSupportedError(t *testing.T) {
|
||||
g := mysql.New()
|
||||
r := httptest.NewRequest(http.MethodGet, "/c/abc", nil)
|
||||
|
||||
evt, resp, err := g.Trigger(
|
||||
context.Background(),
|
||||
newMySQLToken("abc"),
|
||||
r,
|
||||
)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, mysql.ErrHTTPTriggerNotSupported)
|
||||
require.Nil(t, evt, "no event when HTTP-triggered (mysql uses TCP)")
|
||||
require.Nil(t, resp, "no response when HTTP-triggered (mysql uses TCP)")
|
||||
}
|
||||
|
||||
func TestTrigger_NilTokenAlsoReturnsHTTPNotSupportedError(t *testing.T) {
|
||||
g := mysql.New()
|
||||
r := httptest.NewRequest(http.MethodGet, "/c/abc", nil)
|
||||
|
||||
_, _, err := g.Trigger(context.Background(), nil, r)
|
||||
require.ErrorIs(t, err, mysql.ErrHTTPTriggerNotSupported)
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/docx"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/envfile"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/kubeconfig"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/mysql"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pdf"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/slowredirect"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/webbug"
|
||||
|
|
@ -28,5 +29,6 @@ func Build(_ Config) Registry {
|
|||
token.TypePDF: pdf.New(),
|
||||
token.TypeKubeconfig: kubeconfig.New(),
|
||||
token.TypeEnvfile: envfile.New(),
|
||||
token.TypeMySQL: mysql.New(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,14 @@ func TestBuild_RegistersEnvfile(t *testing.T) {
|
|||
require.Equal(t, token.TypeEnvfile, g.Type())
|
||||
}
|
||||
|
||||
func TestBuild_RegistersMySQL(t *testing.T) {
|
||||
reg := registry.Build(registry.Config{BaseURL: testBaseURL})
|
||||
g, ok := reg[token.TypeMySQL]
|
||||
require.True(t, ok, "expected mysql generator registered")
|
||||
require.NotNil(t, g)
|
||||
require.Equal(t, token.TypeMySQL, g.Type())
|
||||
}
|
||||
|
||||
func TestBuild_UnknownTypeReturnsZeroValue(t *testing.T) {
|
||||
reg := registry.Build(registry.Config{BaseURL: testBaseURL})
|
||||
g, ok := reg["nonexistent-type"]
|
||||
|
|
@ -69,28 +77,25 @@ func TestBuild_UnknownTypeReturnsZeroValue(t *testing.T) {
|
|||
require.Nil(t, g, "map zero value for missing key must be nil interface")
|
||||
}
|
||||
|
||||
func TestBuild_PendingTypesNotYetRegistered(t *testing.T) {
|
||||
reg := registry.Build(registry.Config{BaseURL: testBaseURL})
|
||||
pending := []token.Type{
|
||||
token.TypeMySQL,
|
||||
}
|
||||
for _, tt := range pending {
|
||||
_, ok := reg[tt]
|
||||
require.False(
|
||||
t,
|
||||
ok,
|
||||
"type %q is not yet registered (subsequent phases will add it); registry must not claim it",
|
||||
tt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_OnlyExpectedTypesPresentInPhase7(t *testing.T) {
|
||||
func TestBuild_AllSevenGeneratorsRegistered(t *testing.T) {
|
||||
reg := registry.Build(registry.Config{BaseURL: testBaseURL})
|
||||
require.Len(
|
||||
t,
|
||||
reg,
|
||||
6,
|
||||
"Phase 7 registers exactly six generators (webbug, slowredirect, docx, pdf, kubeconfig, envfile); other phases append",
|
||||
7,
|
||||
"Phase 8 closes the generator set with all 7 types registered (webbug, slowredirect, docx, pdf, kubeconfig, envfile, mysql)",
|
||||
)
|
||||
|
||||
for _, ty := range []token.Type{
|
||||
token.TypeWebbug,
|
||||
token.TypeSlowRedirect,
|
||||
token.TypeDocx,
|
||||
token.TypePDF,
|
||||
token.TypeKubeconfig,
|
||||
token.TypeEnvfile,
|
||||
token.TypeMySQL,
|
||||
} {
|
||||
_, ok := reg[ty]
|
||||
require.True(t, ok, "type %q must be registered", ty)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue