Initial commit.
This commit is contained in:
21
.env.example
Normal file
21
.env.example
Normal 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
16
Dockerfile
Normal 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
344
cmd/ldap-s3-monitor/main.go
Normal 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
13
go.mod
Normal 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
|
||||
)
|
||||
Reference in New Issue
Block a user