Initial commit.

This commit is contained in:
2025-11-02 13:30:17 +00:00
commit 0c6079771a
4 changed files with 394 additions and 0 deletions

21
.env.example Normal file
View File

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

16
Dockerfile Normal file
View File

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

344
cmd/ldap-s3-monitor/main.go Normal file
View File

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

13
go.mod Normal file
View File

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