diff --git a/.github/workflows/build-and-publish-ldap-sync.yml b/.github/workflows/build-and-publish-ldap-sync.yml index 32887e7..ff2ead5 100644 --- a/.github/workflows/build-and-publish-ldap-sync.yml +++ b/.github/workflows/build-and-publish-ldap-sync.yml @@ -1,36 +1,51 @@ -name: build-and-publish-ldap-sync +name: build-and-publish on: push: - branches: [ "main", "dev" ] + branches: [ "main", "dev", "v*" ] + tags: [ "v*" ] paths: - "ldap-sync/**" - ".github/workflows/build-and-publish-ldap-sync.yml" - tags: - - "ldap-sync-v*" + - "VERSION" + - "MAKEFILE" + - "ldap-sync-full-stack-dev_docker-compose.yml" + - "ldap-sync-full-stack-dev.env" pull_request: branches: [ "main" ] paths: - "ldap-sync/**" - ".github/workflows/build-and-publish-ldap-sync.yml" + - "VERSION" + - "MAKEFILE" + - "ldap-sync-full-stack-dev_docker-compose.yml" + - "ldap-sync-full-stack-dev.env" jobs: docker: runs-on: ubuntu-latest permissions: - contents: write + contents: write # create releases packages: write - id-token: write + id-token: write # for provenance env: IMAGE: modomofn/ldap-sync - CONTEXT_DIR: ldap-sync - DOCKERFILE: ldap-sync/Dockerfile steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Set up Go cache (optional) + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -47,19 +62,51 @@ jobs: id: vars shell: bash run: | + set -euo pipefail + SHA="${GITHUB_SHA::7}" echo "sha=$SHA" >> $GITHUB_OUTPUT - # channel tags for branches - if [[ "$GITHUB_REF" == "refs/heads/main" ]]; then - echo "channel=latest" >> $GITHUB_OUTPUT - elif [[ "$GITHUB_REF" == "refs/heads/dev" ]]; then - echo "channel=edge" >> $GITHUB_OUTPUT + VERSION_FILE="VERSION" + VERSION="" + if [[ -f "$VERSION_FILE" ]]; then + VERSION=$(tr -d '\r\n' < "$VERSION_FILE") + VERSION=${VERSION#v} fi + if [[ -n "$VERSION" ]]; then + echo "version_plain=$VERSION" >> $GITHUB_OUTPUT + fi + + case "$GITHUB_REF" in + refs/heads/main) + echo "channel=latest" >> $GITHUB_OUTPUT + if [[ -n "$VERSION" ]]; then + echo "version_tag=v$VERSION" >> $GITHUB_OUTPUT + fi + ;; + refs/heads/dev) + echo "channel=edge" >> $GITHUB_OUTPUT + if [[ -n "$VERSION" ]]; then + echo "dev_version_tag=v${VERSION}-dev-$SHA" >> $GITHUB_OUTPUT + fi + ;; + refs/heads/v[0-9]*) + BRANCH_VERSION="${GITHUB_REF#refs/heads/}" + if [[ -n "$VERSION" ]]; then + echo "version_tag=v$VERSION" >> $GITHUB_OUTPUT + else + echo "version_tag=$BRANCH_VERSION" >> $GITHUB_OUTPUT + fi + if [[ "$BRANCH_VERSION" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "ver=$BRANCH_VERSION" >> $GITHUB_OUTPUT + echo "major=v${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT + echo "minor=v${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" >> $GITHUB_OUTPUT + fi + ;; + esac - # semver if tag matches ldap-sync-vX.Y.Z - if [[ "$GITHUB_REF" =~ refs/tags/ldap-sync-v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then - VER="${BASH_REMATCH[0]#refs/tags/ldap-sync-}" # vX.Y.Z + if [[ "$GITHUB_REF" =~ refs/tags/v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + VER="${BASH_REMATCH[0]#refs/tags/}" MAJOR="v${BASH_REMATCH[1]}" MINOR="v${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" echo "ver=$VER" >> $GITHUB_OUTPUT @@ -70,8 +117,8 @@ jobs: - name: Build and push (multi-arch) uses: docker/build-push-action@v6 with: - context: ${{ env.CONTEXT_DIR }} - file: ${{ env.DOCKERFILE }} + context: ldap-sync + file: ldap-sync/Dockerfile platforms: linux/amd64,linux/arm64 push: ${{ github.event_name != 'pull_request' }} provenance: true @@ -79,14 +126,15 @@ jobs: tags: | ${{ env.IMAGE }}:${{ steps.vars.outputs.sha }} ${{ steps.vars.outputs.channel && format('{0}:{1}', env.IMAGE, steps.vars.outputs.channel) || '' }} + ${{ steps.vars.outputs.version_tag && format('{0}:{1}', env.IMAGE, steps.vars.outputs.version_tag) || '' }} + ${{ steps.vars.outputs.dev_version_tag && format('{0}:{1}', env.IMAGE, steps.vars.outputs.dev_version_tag) || '' }} ${{ steps.vars.outputs.ver && format('{0}:{1}', env.IMAGE, steps.vars.outputs.ver) || '' }} ${{ steps.vars.outputs.minor && format('{0}:{1}', env.IMAGE, steps.vars.outputs.minor) || '' }} ${{ steps.vars.outputs.major && format('{0}:{1}', env.IMAGE, steps.vars.outputs.major) || '' }} - - name: Create GitHub Release (for ldap-sync tags) - if: startsWith(github.ref, 'refs/tags/ldap-sync-v') + + - name: Create GitHub Release (for tags) + if: startsWith(github.ref, 'refs/tags/v') uses: softprops/action-gh-release@v2 with: - tag_name: ${{ github.ref_name }} - name: ldap-sync ${{ github.ref_name }} - generate_release_notes: true + generate_release_notes: true \ No newline at end of file diff --git a/README.md b/README.md index 579ed90..86cde4c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,92 @@ -# ldap-sync -Companion app for modom-ofn/auth-portal +# ldap-sync (AuthPortal Companion) + +[![Docker Pulls](https://img.shields.io/docker/pulls/modomofn/ldap-sync.svg)](https://hub.docker.com/r/modomofn/ldap-sync) +[![Docker Image Size](https://img.shields.io/docker/image-size/modomofn/ldap-sync/latest)](https://hub.docker.com/r/modomofn/ldap-sync) +[![Go Version](https://img.shields.io/badge/Go-1.25.3%2B-00ADD8?logo=go)](https://go.dev/) +[![License: GPL-3.0](https://img.shields.io/badge/License-GPL3.0-green.svg)](https://github.com/modom-ofn/ldap-sync?tab=GPL-3.0-1-ov-file#readme) +[![Vibe Coded](https://img.shields.io/badge/Vibe_Coded-OpenAI_Codex-purple)](https://developers.openai.com/codex/windows) + +**ldap-sync** is the lightweight bridge that keeps your directory in lockstep with [AuthPortal](https://github.com/modom-ofn/auth-portal). It speaks Postgres on one side and LDAP on the other, exporting only the users that AuthPortal has approved for downstream apps. + +> [!NOTE] +> Before diving in, make sure AuthPortal is up and humming. ldap-sync simply mirrors its source of truth. + +> [!IMPORTANT] +> **Use at your own risk.** This project leans on Vibe Coding practices - AI pair-programming, automated refactors, and rapid iteration. Treat releases as starting points - test, monitor, and adapt to your stack. AuthPortal and ldap-sync remains an independent effort with no endorsement from Plex, Emby, or Jellyfin. + +> [!NOTE] +> - Docker Hub: https://hub.docker.com/r/modomofn/ldap-sync +> - GitHub Repo: https://github.com/modom-ofn/ldap-sync + +--- + +## Why it exists + +- **Downstream SSO without duct tape** + Sync AuthPortal's authorized media users into LDAP so legacy services (Grafana, Jenkins, whatever) can reuse the same access decisions. + +- **Multi-provider aware** + Ships the full identities matrix (Plex, Jellyfin, Emby) right into LDAP `description` attributes for cross-reference. Works with every AuthPortal `v2.0.x` release, including the latest [v2.0.3](https://github.com/modom-ofn/auth-portal/). + +- **Zero-click OU bootstrap** + Creates the target OU branch at startup if it is missing, so fresh installs do not require manual LDIF prep. + +- **Container ready** + Single binary, stateless. Point at your Postgres + LDAP endpoints, mount env vars, and let it stream changes on schedule (cron, systemd, Kubernetes Job, you pick). + +--- + +## How it works + +1. **Connect** to the AuthPortal database using `DATABASE_URL` (defaults to the docker-compose setup). +2. **Read** either the `identities` table (multi-provider era) or the legacy `users` table, trimming to rows with `media_access = TRUE`. +3. **Bind** to LDAP via `LDAP_HOST` and `LDAP_ADMIN_DN`, optionally StartTLS. +4. **Ensure** the base OU exists. ldap-sync will create `ou=users` under your domain component if needed. +5. **Upsert** each entry as `inetOrgPerson`, writing email + provider metadata into LDAP attributes. Existing entries get patched; missing ones are added. + +All operations run within sane timeouts and log progress in plain English so you can wire it into any orchestrator. + +--- + +## Configuration snapshot + +| Variable | Default | Purpose | +| --- | --- | --- | +| `DATABASE_URL` | `postgres://authportal:change-me@postgres:5432/authportaldb?sslmode=disable` | AuthPortal Postgres connection string | +| `LDAP_HOST` | `ldap://openldap:389` | Target LDAP endpoint (supports `ldaps://`) | +| `LDAP_ADMIN_DN` | `cn=admin,dc=authportal,dc=local` | Bind DN | +| `LDAP_ADMIN_PASSWORD` | *(empty)* | Bind credential | +| `BASE_DN` | `ou=users,dc=authportal,dc=local` | Where entries are stored/created | +| `LDAP_STARTTLS` | `false` | Enable StartTLS negotiation | + +Add your own scheduler (cron, k8s job, GitHub Actions, etc.) to run the binary as often as you need. + +--- + +## Getting Started + +```bash +# build the container +docker build -t modomofn/ldap-sync:latest ./ldap-sync + +# run against a local stack +docker run --rm \ + -e DATABASE_URL=postgres://authportal:change-me@postgres:5432/authportaldb?sslmode=disable \ + -e LDAP_HOST=ldap://openldap:389 \ + -e LDAP_ADMIN_DN="cn=admin,dc=authportal,dc=local" \ + -e LDAP_ADMIN_PASSWORD=supersecret \ + modomofn/ldap-sync:dev +``` + +When the run completes you will see logs for any adds/updates. Rerun anytime AuthPortal grants or revokes access. + +--- + +## Need the portal itself? + +ldap-sync is a supporting actor. The feature-rich headliner lives here: **[AuthPortal on GitHub](https://github.com/modom-ofn/auth-portal)**. Start there to bootstrap your auth stack, then drop ldap-sync in to keep everything else in sync. + +--- + +> [!IMPORTANT] +> **Use at your own risk.** This project leans on Vibe Coding practices - AI pair-programming, automated refactors, and rapid iteration. Treat releases as starting points - test, monitor, and adapt to your stack. AuthPortal and ldap-sync remains an independent effort with no endorsement from Plex, Emby, or Jellyfin. \ No newline at end of file diff --git a/ldap-sync/Dockerfile b/ldap-sync/Dockerfile index 0b19478..4d88485 100644 --- a/ldap-sync/Dockerfile +++ b/ldap-sync/Dockerfile @@ -1,5 +1,5 @@ # ---- builder ---- -FROM golang:1.23.12-alpine3.21 AS build +FROM golang:1.25.3-alpine3.21 AS build WORKDIR /src # copy only go.mod first (no go.sum yet) diff --git a/ldap-sync/VERSION b/ldap-sync/VERSION index 10bf840..50ffc5a 100644 --- a/ldap-sync/VERSION +++ b/ldap-sync/VERSION @@ -1 +1 @@ -2.0.1 \ No newline at end of file +2.0.3 diff --git a/ldap-sync/go.mod b/ldap-sync/go.mod index 4b7bb87..53c86ae 100644 --- a/ldap-sync/go.mod +++ b/ldap-sync/go.mod @@ -1,9 +1,17 @@ module ldap-sync -go 1.23.8 -toolchain go1.23.12 +go 1.25 + +toolchain go1.25.3 require ( github.com/go-ldap/ldap/v3 v3.4.6 github.com/lib/pq v1.10.9 ) + +require ( + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect + github.com/google/uuid v1.3.1 // indirect + golang.org/x/crypto v0.13.0 // indirect +) diff --git a/ldap-sync/go.sum b/ldap-sync/go.sum new file mode 100644 index 0000000..5000dd3 --- /dev/null +++ b/ldap-sync/go.sum @@ -0,0 +1,65 @@ +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= +github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA= +github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA= +github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-ldap/ldap/v3 v3.4.6 h1:ert95MdbiG7aWo/oPYp9btL3KJlMPKnP58r09rI8T+A= +github.com/go-ldap/ldap/v3 v3.4.6/go.mod h1:IGMQANNtxpsOzj7uUAMjpGBaOVTC4DYyIy8VsTdxmtc= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/ldap-sync/main.go b/ldap-sync/main.go index f8033f9..d8c8948 100644 --- a/ldap-sync/main.go +++ b/ldap-sync/main.go @@ -7,6 +7,7 @@ import ( "log" "net" "os" + "sort" "strings" "time" @@ -41,12 +42,73 @@ func envOr(k, d string) string { // Matches dev-r2 DB schema (media_* fields) type rowUser struct { - Username string - Email sql.NullString - MediaUUID sql.NullString + Username string + Email sql.NullString + MediaUUID sql.NullString MediaAccess bool // not scanned directly; filtered in SQL } +type identityInfo struct { + Provider string + MediaUUID string +} + +type syncUser struct { + Username string + Email sql.NullString + idents map[string]identityInfo +} + +func (u *syncUser) addIdentity(provider, mediaUUID string) { + if u == nil { + return + } + provider = strings.TrimSpace(provider) + mediaUUID = strings.TrimSpace(mediaUUID) + if provider == "" && mediaUUID == "" { + return + } + if u.idents == nil { + u.idents = make(map[string]identityInfo) + } + key := provider + "\x00" + mediaUUID + if _, exists := u.idents[key]; exists { + return + } + u.idents[key] = identityInfo{ + Provider: provider, + MediaUUID: mediaUUID, + } +} + +func (u *syncUser) identityList() []identityInfo { + if u == nil || len(u.idents) == 0 { + return nil + } + out := make([]identityInfo, 0, len(u.idents)) + for _, info := range u.idents { + out = append(out, info) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Provider == out[j].Provider { + return out[i].MediaUUID < out[j].MediaUUID + } + return out[i].Provider < out[j].Provider + }) + return out +} + +func normalizeNullString(ns sql.NullString) sql.NullString { + if !ns.Valid { + return sql.NullString{} + } + trimmed := strings.TrimSpace(ns.String) + if trimmed == "" { + return sql.NullString{} + } + return sql.NullString{String: trimmed, Valid: true} +} + func main() { // ---- Postgres db, err := sql.Open("postgres", dbURL) @@ -63,37 +125,97 @@ func main() { } } - // Prefer identities (multi-provider) when available; fallback to legacy users - ctx, cancel := context.WithTimeout(context.Background(), dbTimeout) - defer cancel() + // Prefer identities (multi-provider) when available; fallback to legacy users + ctx, cancel := context.WithTimeout(context.Background(), dbTimeout) + defer cancel() + + var identitiesAvailable bool + if err := db.QueryRowContext(ctx, ` +SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'identities' +)`).Scan(&identitiesAvailable); err != nil { + log.Fatalf("identities table check: %v", err) + } - var useIdentities bool - if err := db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM identities WHERE media_access = TRUE)`).Scan(&useIdentities); err != nil { - log.Fatalf("identities exists: %v", err) - } + var ( + rows *sql.Rows + queryErr error + useIdentities bool + ) - var rows *sql.Rows - if useIdentities { - rows, err = db.QueryContext(ctx, ` + if identitiesAvailable { + rows, queryErr = db.QueryContext(ctx, ` SELECT u.username, u.email, i.media_uuid, i.provider FROM identities i JOIN users u ON u.id = i.user_id WHERE i.media_access = TRUE - ORDER BY u.username`) - if err != nil { - log.Fatalf("query identities: %v", err) - } - } else { - rows, err = db.QueryContext(ctx, ` + ORDER BY u.username, i.provider`) + if queryErr != nil { + log.Printf("identities query failed (falling back to legacy users): %v", queryErr) + } else { + useIdentities = true + } + } + + if rows == nil { + rows, queryErr = db.QueryContext(ctx, ` SELECT username, email, media_uuid FROM users WHERE media_access = TRUE ORDER BY username`) - if err != nil { - log.Fatalf("query users: %v", err) - } - } - defer rows.Close() + if queryErr != nil { + log.Fatalf("query users: %v", queryErr) + } + } + defer rows.Close() + + users := make(map[string]*syncUser) + + for rows.Next() { + var ( + username string + email sql.NullString + mediaUUID sql.NullString + provider string + ) + if useIdentities { + if err := rows.Scan(&username, &email, &mediaUUID, &provider); err != nil { + log.Printf("scan row (identities): %v", err) + continue + } + } else { + if err := rows.Scan(&username, &email, &mediaUUID); err != nil { + log.Printf("scan row (users): %v", err) + continue + } + provider = inferProviderFromUUID(mediaUUID.String) + } + + username = strings.TrimSpace(username) + if username == "" { + log.Println("skipping user with empty username") + continue + } + + entry, ok := users[username] + if !ok { + entry = &syncUser{Username: username} + users[username] = entry + } + + if normEmail := normalizeNullString(email); normEmail.Valid { + if !entry.Email.Valid || entry.Email.String == "" { + entry.Email = normEmail + } + } + + entry.addIdentity(provider, mediaUUID.String) + } + if err := rows.Err(); err != nil { + log.Printf("rows error: %v", err) + } // ---- LDAP connect & bind l, err := dialLDAP(ldapHost) @@ -116,31 +238,15 @@ SELECT username, email, media_uuid if err := ensureOUExists(l, baseDN); err != nil { log.Fatalf("ensureOUExists(%s): %v", baseDN, err) } + // Process aggregated users + usernames := make([]string, 0, len(users)) + for username := range users { + usernames = append(usernames, username) + } + sort.Strings(usernames) - // Process rows - for rows.Next() { - var username string - var email sql.NullString - var mediaUUID sql.NullString - var provider string - if useIdentities { - if err := rows.Scan(&username, &email, &mediaUUID, &provider); err != nil { - log.Printf("scan row (identities): %v", err) - continue - } - } else { - if err := rows.Scan(&username, &email, &mediaUUID); err != nil { - log.Printf("scan row (users): %v", err) - continue - } - provider = inferProviderFromUUID(mediaUUID.String) - } - - username = strings.TrimSpace(username) - if username == "" { - log.Println("skipping user with empty username") - continue - } + for _, username := range usernames { + entry := users[username] // Build DN userDN := fmt.Sprintf("uid=%s,%s", ldapEscape(username), baseDN) @@ -153,17 +259,19 @@ SELECT username, email, media_uuid } // Build auxiliary attributes - mailVals := []string{} - if email.Valid && strings.TrimSpace(email.String) != "" { - mailVals = []string{strings.TrimSpace(email.String)} - } - descVals := []string{} - if strings.TrimSpace(provider) != "" { - descVals = append(descVals, "provider="+strings.TrimSpace(provider)) - } - if mediaUUID.Valid && strings.TrimSpace(mediaUUID.String) != "" { - descVals = append(descVals, "media_uuid="+strings.TrimSpace(mediaUUID.String)) - } + mailVals := []string{} + if entry.Email.Valid { + mailVals = append(mailVals, entry.Email.String) + } + descVals := []string{} + for _, ident := range entry.identityList() { + if ident.Provider != "" { + descVals = append(descVals, "provider="+ident.Provider) + } + if ident.MediaUUID != "" { + descVals = append(descVals, "media_uuid="+ident.MediaUUID) + } + } if !exists { // Add new inetOrgPerson (include standard aux classes for compatibility) @@ -197,8 +305,8 @@ SELECT username, email, media_uuid modReq.Replace("uid", []string{username}) // Optional fields - modReq.Replace("mail", mailVals) // empty slice clears attribute - modReq.Replace("description", descVals) // store media_uuid for reference + modReq.Replace("mail", mailVals) // empty slice clears attribute + modReq.Replace("description", descVals) // store media_uuid for reference if err := l.Modify(modReq); err != nil { log.Printf("LDAP modify %s: %v", userDN, err) @@ -206,10 +314,6 @@ SELECT username, email, media_uuid } log.Printf("LDAP updated: %s", userDN) } - - if err := rows.Err(); err != nil { - log.Printf("rows error: %v", err) - } } // dialLDAP supports ldap:// and ldaps://, or host:port (plain) @@ -315,15 +419,15 @@ func firstRDNValue(dnLower, key string) string { // inferProviderFromUUID returns provider based on a media_uuid prefix. func inferProviderFromUUID(u string) string { - s := strings.ToLower(strings.TrimSpace(u)) - switch { - case strings.HasPrefix(s, "plex-"): - return "plex" - case strings.HasPrefix(s, "emby-"): - return "emby" - case strings.HasPrefix(s, "jellyfin-"): - return "jellyfin" - default: - return "" - } + s := strings.ToLower(strings.TrimSpace(u)) + switch { + case strings.HasPrefix(s, "plex-"): + return "plex" + case strings.HasPrefix(s, "emby-"): + return "emby" + case strings.HasPrefix(s, "jellyfin-"): + return "jellyfin" + default: + return "" + } }