commit 0c6079771a2c5caf3a2a7f7b4f5c0b0c6ca728ff Author: Eric Rakestraw Date: Sun Nov 2 13:30:17 2025 +0000 Initial commit. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cbe7360 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# LDAP connection +LDAP_URI=ldaps://ldap.example.net:636 +LDAP_BIND_DN=uid=admin,dc=example,dc=net +LDAP_BIND_PASSWORD=changeme +LDAP_BASE_DN=ou=clients,dc=example,dc=net +LDAP_FILTER=(objectClass=MAILACCOUNT) +LDAP_ATTR_ACCESS_KEY=S3ACCESSKEY +LDAP_ATTR_SECRET_KEY=S3SECRETKEY +# If using ldap:// and you want StartTLS +LDAP_STARTTLS=false + + +# S3 endpoint +S3_ENDPOINT=https://s3.example.com +S3_REGION=us-east-1 +S3_FORCE_PATH_STYLE=true + + +# Service behavior +POLL_INTERVAL=30s +WORKERS=4 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..171e7e0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +# ---- build stage ---- +FROM golang:1.23 AS build +WORKDIR /src +COPY go.mod ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /out/s3-ldap-monitor ./cmd/s3-ldap-monitor + + +# ---- run stage ---- +FROM gcr.io/distroless/static-debian12 +WORKDIR /app +COPY --from=build /out/s3-ldap-monitor /app/s3-ldap-monitor +USER 65532:65532 +ENV GODEBUG=madvdontneed=1 +ENTRYPOINT ["/app/s3-ldap-monitor"] diff --git a/cmd/ldap-s3-monitor/main.go b/cmd/ldap-s3-monitor/main.go new file mode 100644 index 0000000..862f4f5 --- /dev/null +++ b/cmd/ldap-s3-monitor/main.go @@ -0,0 +1,344 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "log" + "net" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "sync" + "syscall" + "time" + + aws "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/go-ldap/ldap/v3" +) + +// ------------------------- +// Configuration +// ------------------------- + +type Config struct { + LDAPURI string + LDAPBindDN string + LDAPBindPassword string + LDAPBaseDN string + LDAPFilter string + LDAPAttrAccessKey string // e.g., "s3accesskey" + LDAPAttrSecretKey string // e.g., "s3secretkey" + LDAPStartTLS bool + + S3Endpoint string + S3Region string + S3ForcePathStyle bool + + PollInterval time.Duration + Workers int +} + +func getenv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func parseBoolEnv(key string, def bool) bool { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return def + } + b, err := strconv.ParseBool(v) + if err != nil { + return def + } + return b +} + +func parseDurationEnv(key string, def time.Duration) time.Duration { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return def + } + d, err := time.ParseDuration(v) + if err != nil { + return def + } + return d +} + +func parseIntEnv(key string, def int) int { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return def + } + i, err := strconv.Atoi(v) + if err != nil { + return def + } + return i +} + +func loadConfig() (Config, error) { + cfg := Config{ + LDAPURI: getenv("LDAP_URI", ""), + LDAPBindDN: getenv("LDAP_BIND_DN", ""), + LDAPBindPassword: getenv("LDAP_BIND_PASSWORD", ""), + LDAPBaseDN: getenv("LDAP_BASE_DN", ""), + LDAPFilter: getenv("LDAP_FILTER", "(objectClass=person)"), + LDAPAttrAccessKey: getenv("LDAP_ATTR_ACCESS_KEY", "s3accesskey"), + LDAPAttrSecretKey: getenv("LDAP_ATTR_SECRET_KEY", "s3secretkey"), + LDAPStartTLS: parseBoolEnv("LDAP_STARTTLS", false), + + S3Endpoint: getenv("S3_ENDPOINT", ""), + S3Region: getenv("S3_REGION", "us-east-1"), + S3ForcePathStyle: parseBoolEnv("S3_FORCE_PATH_STYLE", true), + + PollInterval: parseDurationEnv("POLL_INTERVAL", 30*time.Second), + Workers: parseIntEnv("WORKERS", 8), + } + + var missing []string + if cfg.LDAPURI == "" { missing = append(missing, "LDAP_URI") } + if cfg.LDAPBindDN == "" { missing = append(missing, "LDAP_BIND_DN") } + if cfg.LDAPBindPassword == "" { missing = append(missing, "LDAP_BIND_PASSWORD") } + if cfg.LDAPBaseDN == "" { missing = append(missing, "LDAP_BASE_DN") } + if cfg.S3Endpoint == "" { missing = append(missing, "S3_ENDPOINT") } + if len(missing) > 0 { + return cfg, fmt.Errorf("missing required env: %s", strings.Join(missing, ", ")) + } + return cfg, nil +} + +// ------------------------- +// Logging helpers (minimal JSON-style) +// ------------------------- + +func logKV(level string, kv ...any) { + // Simple, safe key=value logger to stdout. + // Avoids dependencies. No secrets should be logged. + var b strings.Builder + b.WriteString(time.Now().UTC().Format(time.RFC3339)) + b.WriteString(" ") + b.WriteString(strings.ToUpper(level)) + for i := 0; i < len(kv); i += 2 { + k := fmt.Sprint(kv[i]) + var v any + if i+1 < len(kv) { v = kv[i+1] } + b.WriteString(" ") + b.WriteString(k) + b.WriteString("=") + b.WriteString(fmt.Sprintf("%v", v)) + } + log.Println(b.String()) +} + +// ------------------------- +// LDAP +// ------------------------- + +type LDAPUser struct { + AccessKey string + SecretKey string +} + +func ldapFetchUsers(ctx context.Context, cfg Config) ([]LDAPUser, error) { + // Dial with timeout + dialer := &net.Dialer{ Timeout: 10 * time.Second } + var l *ldap.Conn + var err error + + if strings.HasPrefix(strings.ToLower(cfg.LDAPURI), "ldaps://") { + l, err = ldap.DialURL(cfg.LDAPURI, ldap.DialWithDialer(dialer)) + if err != nil { return nil, err } + } else if strings.HasPrefix(strings.ToLower(cfg.LDAPURI), "ldap://") { + l, err = ldap.DialURL(cfg.LDAPURI, ldap.DialWithDialer(dialer)) + if err != nil { return nil, err } + if cfg.LDAPStartTLS { + if err := l.StartTLS(nil); err != nil { _ = l.Close(); return nil, err } + } + } else { + return nil, fmt.Errorf("unsupported LDAP_URI scheme: %s", cfg.LDAPURI) + } + defer l.Close() + + // Bind service account + if err := l.Bind(cfg.LDAPBindDN, cfg.LDAPBindPassword); err != nil { + return nil, fmt.Errorf("ldap bind failed: %w", err) + } + + searchReq := ldap.NewSearchRequest( + cfg.LDAPBaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 15, false, + cfg.LDAPFilter, + []string{cfg.LDAPAttrAccessKey, cfg.LDAPAttrSecretKey}, + nil, + ) + + res, err := l.SearchWithPaging(searchReq, 500) + if err != nil { return nil, err } + + users := make([]LDAPUser, 0, len(res.Entries)) + for _, e := range res.Entries { + ak := strings.TrimSpace(e.GetAttributeValue(cfg.LDAPAttrAccessKey)) + sk := strings.TrimSpace(e.GetAttributeValue(cfg.LDAPAttrSecretKey)) + if ak == "" || sk == "" { continue } + users = append(users, LDAPUser{AccessKey: ak, SecretKey: sk}) + } + return users, nil +} + +// ------------------------- +// S3 +// ------------------------- + +type S3ClientFactory struct { + endpoint string + region string + forcePathStyle bool + httpClient *http.Client +} + +func newS3Factory(cfg Config) *S3ClientFactory { + // Tight but reasonable client timeouts + hc := &http.Client{ Timeout: 15 * time.Second } + return &S3ClientFactory{ + endpoint: cfg.S3Endpoint, + region: cfg.S3Region, + forcePathStyle: cfg.S3ForcePathStyle, + httpClient: hc, + } +} + +func (f *S3ClientFactory) clientForUser(accessKey, secretKey string) (*s3.Client, error) { + if accessKey == "" || secretKey == "" { return nil, errors.New("empty credentials") } + + creds := credentials.NewStaticCredentialsProvider(accessKey, secretKey, "") + + resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) { + if strings.TrimSpace(f.endpoint) != "" { + return aws.Endpoint{ + URL: f.endpoint, + HostnameImmutable: true, + }, nil + } + return aws.Endpoint{}, &aws.EndpointNotFoundError{} + }) + + cfg := aws.Config{ + Region: f.region, + Credentials: aws.NewCredentialsCache(creds), + EndpointResolverWithOptions: resolver, + HTTPClient: f.httpClient, + } + + return s3.NewFromConfig(cfg, func(o *s3.Options) { + o.UsePathStyle = f.forcePathStyle + }), nil +} + +func listBucketsForUser(ctx context.Context, cli *s3.Client) ([]string, error) { + out, err := cli.ListBuckets(ctx, &s3.ListBucketsInput{}) + if err != nil { return nil, err } + b := make([]string, 0, len(out.Buckets)) + for _, v := range out.Buckets { + if v.Name != nil { b = append(b, *v.Name) } + } + return b, nil +} + +// ------------------------- +// Utility +// ------------------------- + +func safeUserID(accessKey string) string { + // Hash the access key to avoid logging PII/credentials. + h := sha256.Sum256([]byte(accessKey)) + return hex.EncodeToString(h[:])[:12] +} + +// ------------------------- +// Main loop +// ------------------------- + +func runOnce(ctx context.Context, cfg Config, fac *S3ClientFactory) error { + start := time.Now() + users, err := ldapFetchUsers(ctx, cfg) + if err != nil { + return err + } + logKV("info", "phase", "ldap_done", "users", len(users), "t_ms", time.Since(start).Milliseconds()) + + sem := make(chan struct{}, cfg.Workers) + var wg sync.WaitGroup + + for _, u := range users { + u := u + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + + uid := safeUserID(u.AccessKey) + cli, err := fac.clientForUser(u.AccessKey, u.SecretKey) + if err != nil { + logKV("warn", "event", "s3_client_error", "user", uid, "err", err) + return + } + + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + buckets, err := listBucketsForUser(ctx, cli) + if err != nil { + logKV("warn", "event", "list_buckets_failed", "user", uid, "err", err) + return + } + logKV("info", "event", "user_buckets", "user", uid, "count", len(buckets), "buckets", strings.Join(buckets, ",")) + }() + } + + wg.Wait() + return nil +} + +func main() { + cfg, err := loadConfig() + if err != nil { + logKV("error", "event", "config_error", "err", err) + os.Exit(2) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + fac := newS3Factory(cfg) + logKV("info", "event", "startup", "poll_interval", cfg.PollInterval, "workers", cfg.Workers) + + ticker := time.NewTicker(cfg.PollInterval) + defer ticker.Stop() + + for { + cycleStart := time.Now() + if err := runOnce(ctx, cfg, fac); err != nil { + logKV("error", "event", "run_once_failed", "err", err) + } + logKV("info", "event", "cycle_complete", "t_ms", time.Since(cycleStart).Milliseconds()) + + select { + case <-ctx.Done(): + logKV("info", "event", "shutdown") + return + case <-ticker.C: + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4d4ea79 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module maximumdirect/ldap-s3-monitor + + +go 1.23 + + +require ( +github.com/aws/aws-sdk-go-v2 v1.30.5 +github.com/aws/aws-sdk-go-v2/config v1.27.18 +github.com/aws/aws-sdk-go-v2/credentials v1.17.41 +github.com/aws/aws-sdk-go-v2/service/s3 v1.68.0 +github.com/go-ldap/ldap/v3 v3.4.8 +)