Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ jobs:
with:
username: ${{ secrets.DEPLOY_DOCKER_USERNAME }}
password: ${{ secrets.DEPLOY_DOCKER_PASSWORD }}
- name: Provide licensing secrets
run: |
echo "${{ secrets.KEY_BIN }}" | base64 --decode > ./key.bin
echo "${{ secrets.SERVER_BIN }}" | base64 --decode > ./server.bin
Comment on lines +47 to +48

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, this feels pretty clean.

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: build-push
Expand Down
33 changes: 33 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,36 @@ __debug_bin
/imqsauth.exe
/log/
/imqsauth.log
/licenses_client/
/client.key
/client.pub
/tools/listanddiff/go_build_listanddiff_go.exe
/id_rsa
/key.bin
/tools/listanddiff/listanddiff.exe
/tools/listanddiff/output.csv
/server.bin
/server.key
/testconf/tempconf_live.json
/testconf/tempconf_live_imqs-entra.json
/testconf/tempconf_local.json
/server.pub
/frontend/node_modules/
/frontend/dist/
/licenses_client/
/t1/
/t2/
/t3/
/keys/
/server.bin
/key.bin
/tools/listanddiff/go_build_listanddiff_go.exe
/id_rsa
/client.key
/client.pub
/test_ldap/imqsauth.exe
/imqsauth.exe.garble
/tools/listanddiff/listanddiff.exe
/httpfront/static/assets/
/tools/listanddiff/output.csv
/test_ldap/log/imqsauth.log
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
##################################
# Builder image
##################################
FROM golang:1.22 AS builder
FROM golang:1.24 AS builder

# Authorize SSH Host
RUN mkdir -p /root/.ssh && \
Expand All @@ -24,7 +24,7 @@ RUN --mount=type=ssh \

# Compile
COPY . /build/
RUN go build imqsauth.go
RUN go build -o imqsauth -tags prod .

##################################
# Deployed image
Expand Down
168 changes: 165 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,140 @@ More information can be found in Confluence: [Imqs Auth](https://imqssoftware.at

## Building

To build imqsauth:
### Quick build

A build tag must **always** be specified — omitting it is a compile error:

go build -tags prod . # production (requires server.bin and key.bin)
go build -tags dev . # development (no license required, no key files needed)

go build imqsauth.go
### Dev build (no license required)

For local development, build with the `dev` tag. This skips the `//go:embed`
key file requirement entirely and disables all license checks at runtime —
no `server.bin`, `key.bin`, or `licenses_client` folder needed:

go build -tags dev -o imqsauth.exe .

A warning is printed on startup to make it clear the service is running in
dev mode:

DEV MODE: license checks are disabled (built with -tags dev or no key files embedded)

**Do not use a dev build in production.**

### License Management Considerations

The build requires two binary files to be present in the root of the repository
before `go build` is run, because they are compiled directly into the binary via
`//go:embed`:

| File | Purpose |
|---|---|
| `server.bin` | XOR-masked server public key used by the LicenseClient library |
| `key.bin` | XOR mask used at runtime to recover the real public key from `server.bin` |

Both files are excluded from source control (`.gitignore`). See
[Generating the embedded key files](#generating-the-embedded-key-files) below.

### Build pipeline (WINDOWS)

We use an older version of Jenkins at the moment, which supports specifying
a secrets _file_.

- Upload both `server.bin` and `key.bin` to Jenkins as **Secret file** credentials
(**not** Secret text — Secret text stores the file *contents* as a string, so
the bound variable will contain binary data instead of a file path, causing
`copy` to fail with *"The system cannot find the file specified"*):
- Go to **Jenkins → Manage Jenkins → Credentials → (store) → Add Credentials**
- Set **Kind** = `Secret file`, upload the file, set the ID:
- _LICENSE_CLIENT_KEY_BIN_ — upload `key.bin`
- _LICENSE_CLIENT_SERVER_BIN_ — upload `server.bin`
- Add them to the build pipeline as secret files, and assign them to named
environment variables:
- _LICENSE_CLIENT_KEY_BIN_ → `KEY_BIN`
- _LICENSE_CLIENT_SERVER_BIN_ → `SERVER_BIN`
- Copy the files into the correct location before building using **Windows Batch**
commands. Jenkins injects the secret file path using forward slashes, which the
Windows `copy` command does not handle correctly as a source path — convert them
to backslashes first:
```bat
set KEY_BIN_WIN=%KEY_BIN:/=\%
set SERVER_BIN_WIN=%SERVER_BIN:/=\%
copy "%KEY_BIN_WIN%" "%WORKSPACE%\imqsauth\key.bin"
copy "%SERVER_BIN_WIN%" "%WORKSPACE%\imqsauth\server.bin"
cd %WORKSPACE%\imqsauth && go build -tags prod -o imqsauth.exe .
```
> **Note:** if `imqsauth` is checked out as a submodule inside a larger
> workspace (e.g. `C:\Jenkins\workspace\Build-RC\imqsauth\`), the destination
> path should include the submodule folder as shown above. If this repository
> *is* the workspace root, use `%WORKSPACE%\key.bin` / `%WORKSPACE%\server.bin`
> directly. Either way the files must end up alongside `imqsauth.go`, because
> the `go:embed` directives resolve paths relative to the `.go` source file.
- Clean up afterwards by deleting the files from the workspace:
```bat
del "%WORKSPACE%\imqsauth\key.bin"
del "%WORKSPACE%\imqsauth\server.bin"
```

### Build pipeline (DOCKER/GitHub Actions)

- Convert the key.bin and server.bin files to base64.
- Add KEY_BIN and SERVER_BIN as secrets to the _repository_ actions secrets and add
their base64 values as the secret values.
- See actions.yml for details on how the files are used in the build pipeline.

### Go module dependencies

All Go dependencies are declared in `go.mod` and fetched automatically by the
Go toolchain. The key external library for license management is:

- `github.com/IMQS/licenseserver v0.0.4` — provides the `LicenseClient` used
to validate the `enterprise` license at runtime. Consumed via two sub-packages:
- `github.com/IMQS/licenseserver/client` — `LicenseClient` struct
- `github.com/IMQS/licenseserver/lib` — `UnmaskPublicKey()` and shared logger

Run `go mod download` to fetch all dependencies before building offline.

### Generating the embedded key files

`server.bin` and `key.bin` are produced from the plaintext public key file
`server.pub` using `xortool`. The source for `xortool` lives in the
`github.com/IMQS/licenseserver` repository. Once you have `xortool.go`:

go build xortool.go
xortool ./server.pub

This writes `server.bin` (the XOR-encoded key) and `key.bin` (the XOR mask)
into the current directory. After that, `go build -tags prod .` will succeed.

### Building the license tooling (`build.bat`)

`build.bat` automates building the supporting license tools. It requires source
files from the `licenseserver` repository (`xortool.go`, `licenseclient.go`,
`licensetool.go`).

rem Build and run xortool to produce server.bin and key.bin from server.pub:
go build xortool.go
xortool ./server.pub

rem Build the license client helper:
go build licenseclient.go

> **Note:** `build.bat` does **not** build `imqsauth.exe` itself. Run
> `go build -tags prod .` separately after the key files have been generated.

_Garble_

We CAN support `garble` (a Go code obfuscator), but there are
problems with anti-virus scanning, especially in production environments and is
currently not used.

rem Install garble (code obfuscation tool):
go install mvdan.cc/garble@v0.15.0

rem Build the license tool with literal obfuscation:
garble --literals build licensetool.go

## Testing

Expand All @@ -25,7 +156,7 @@ in it's own CI job.
The following block demonstrates running all of the tests:

go test github.com/IMQS/imqsauth/auth
go build imqsauth.go
go build -tags dev .
gem install rest-client
ruby resttest.rb

Expand Down Expand Up @@ -64,3 +195,34 @@ The auth service is capable of detecting whether it is running inside or outside
upon startup. It leverages the service discovery mechanism in the config service to transparently
rewrite database connection configurations, as well as other serviceconfig utils to detect whether or not
it is inside the auth service.

### Docker volume mapping — licenseserver 1.0.1+

Starting from **licenseserver 1.0.1**, the `LicenseClient` stores the client keypair
(`client.key` and `client.pub`) under `/keys` inside the container. The service manages
these files itself — it will create them on first run and may regenerate them if they do
not match. Only the **folder** needs to be mapped; do not mount the individual files.

Mount a persistent host directory to `/keys` so the keypair survives container restarts:

**Docker run:**
```sh
docker run \
-v /path/to/keys:/keys \
imqs/auth:latest
```

**Docker Compose:**
```yaml
services:
auth:
image: imqs/auth:latest
volumes:
- /path/to/keys:/keys
```

> **Note:** If you are on an older version of licenseserver (pre-1.0.1) this mapping is
> not required. The change is **breaking** — without a persistent volume at `/keys` the
> service will regenerate its keypair on every restart, which will invalidate any
> previously issued tokens.

29 changes: 22 additions & 7 deletions auth/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"golang.org/x/exp/slices"
"io/ioutil"
"net/http"
"net/url"
Expand All @@ -14,6 +13,8 @@ import (
"sync"
"time"

"golang.org/x/exp/slices"

"github.com/IMQS/authaus"
"github.com/IMQS/imqsauth/utils"
"github.com/IMQS/serviceauth"
Expand Down Expand Up @@ -54,6 +55,7 @@ const (
handlerFlagNeedAdminRights = 1 << iota // Request won't even reach your handler unless the user is an admin
handlerFlagNeedToken // Populate the httpRequest object with 'token' and 'permList'
handlerFlagNeedInterService // Request requires interservice authorization
handlerFlagNoLicense // Request does not require the service to be licensed, so we can still gather metrics/status
)

type httpRequest struct {
Expand Down Expand Up @@ -157,6 +159,16 @@ func (x *ImqsCentral) makeHandler(method HttpMethod, actual func(*ImqsCentral, h
needAdmin := 0 != (flags & handlerFlagNeedAdminRights)
needToken := 0 != (flags & handlerFlagNeedToken)
needInterService := 0 != (flags & handlerFlagNeedInterService)
needLicense := !(0 != (flags & handlerFlagNoLicense))

if needLicense {
isValid := isLicensed()
if !isValid {
authaus.HttpSendTxt(w, http.StatusPaymentRequired, "This service is not licensed to run. Please contact your vendor.")
return
}
}

if !needAdmin && !needToken && !needInterService {
actual(x, w, httpReq)
return
Expand Down Expand Up @@ -211,14 +223,17 @@ func (x *ImqsCentral) makeHandler(method HttpMethod, actual func(*ImqsCentral, h
}

func (x *ImqsCentral) RunHttp() error {
if err := x.initLicenseClient(); err != nil {
return err
}
// The built-in go ServeMux does not support differentiating based on HTTP verb, so we have to make
// the request path unique for each verb. I think this is OK as far as API design is concerned - at least in this domain.
smux := http.NewServeMux()
smux.HandleFunc("/hello", x.makeHandler(HttpMethodGet, httpHandlerHello, 0))
smux.HandleFunc("/ping", x.makeHandler(HttpMethodGet, httpHandlerPing, 0))
smux.HandleFunc("/hostname", x.makeHandler(HttpMethodGet, httpHanderHostname, 0))
smux.HandleFunc("/hello", x.makeHandler(HttpMethodGet, httpHandlerHello, handlerFlagNoLicense))
smux.HandleFunc("/ping", x.makeHandler(HttpMethodGet, httpHandlerPing, handlerFlagNoLicense))
smux.HandleFunc("/hostname", x.makeHandler(HttpMethodGet, httpHanderHostname, handlerFlagNoLicense))
smux.HandleFunc("/login", x.makeHandler(HttpMethodPost, httpHandlerLogin, 0))
smux.HandleFunc("/logout", x.makeHandler(HttpMethodPost, httpHandlerLogout, 0))
smux.HandleFunc("/logout", x.makeHandler(HttpMethodPost, httpHandlerLogout, handlerFlagNoLicense))
smux.HandleFunc("/check", x.makeHandler(HttpMethodGet, httpHandlerCheck, 0))
smux.HandleFunc("/create_user", x.makeHandler(HttpMethodPut, httpHandlerCreateUser, handlerFlagNeedAdminRights))
smux.HandleFunc("/update_user", x.makeHandler(HttpMethodPost, httpHandlerUpdateUser, handlerFlagNeedAdminRights))
Expand All @@ -238,10 +253,10 @@ func (x *ImqsCentral) RunHttp() error {
smux.HandleFunc("/users", x.makeHandler(HttpMethodGet, httpHandlerGetEmails, handlerFlagNeedToken))
smux.HandleFunc("/userobjects", x.makeHandler(HttpMethodGet, httpHandlerGetUsers, handlerFlagNeedAdminRights))
smux.HandleFunc("/userobject", x.makeHandler(HttpMethodGet, httpHandlerGetUser, handlerFlagNeedAdminRights))
smux.HandleFunc("/groups", x.makeHandler(HttpMethodGet, httpHandlerGetGroups, 0))
smux.HandleFunc("/groups", x.makeHandler(HttpMethodGet, httpHandlerGetGroups, handlerFlagNoLicense))
smux.HandleFunc("/exportgroups", x.makeHandler(HttpMethodGet, httpHandlerExportUserGroups, handlerFlagNeedAdminRights))
smux.HandleFunc("/importgroups", x.makeHandler(HttpMethodPost, httpHandlerImportUserGroups, handlerFlagNeedInterService))
smux.HandleFunc("/hasactivedirectory", x.makeHandler(HttpMethodGet, httpHandlerHasActiveDirectory, 0))
smux.HandleFunc("/hasactivedirectory", x.makeHandler(HttpMethodGet, httpHandlerHasActiveDirectory, handlerFlagNoLicense))
smux.HandleFunc("/groups_perm_names", x.makeHandler(HttpMethodGet, httpHandlerGetGroupsPermNames, handlerFlagNeedAdminRights))
smux.HandleFunc("/dynamic_permissions", x.makeHandler(HttpMethodGet, httpHandlerGetDynamicPermissions, 0))
smux.HandleFunc("/oauth/providers", x.makeHandler(HttpMethodGet, httpHandlerOAuthProviders, 0))
Expand Down
16 changes: 16 additions & 0 deletions auth/license_dev.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//go:build dev

package imqsauth

// Dev mode: license client is not compiled in. All license checks pass unconditionally.

func SetLicenseKeys(p, m []byte) {} // no-op in dev mode

func (x *ImqsCentral) initLicenseClient() error {
x.Central.Log.Warnf("DEV MODE: license checks are disabled (built with -tags dev)")
return nil
}

func isLicensed() bool {
return true
}
38 changes: 38 additions & 0 deletions auth/license_prod.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//go:build prod

package imqsauth

import (
"github.com/IMQS/licenseserver/client"
"github.com/IMQS/licenseserver/lib"
)

var pk []byte
var mask []byte

// SetLicenseKeys is called from package main's init() to supply the embedded
// key bytes without exposing them as fields on ImqsCentral.
func SetLicenseKeys(p, m []byte) {
pk = p
mask = m
}

var licenseClient *client.LicenseClient

func (x *ImqsCentral) initLicenseClient() error {
serverPub, e := lib.UnmaskPublicKey(pk, mask)
if e != nil {
return e
}
licenseClient = &client.LicenseClient{}
licenseClient.Init("./licenses_client", serverPub)
licenseClient.LicenseServerURL = "https://deploy.imqs.co.za/licenses/"
licenseClient.Logger = x.Central.Log
lib.L = x.Central.Log
licenseClient.RunClient()
return nil
}

func isLicensed() bool {
return licenseClient.IsLicensed("enterprise")
}
Loading
Loading