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