diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ab88a0..9a8ac78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,9 +6,13 @@ on: pull_request: branches: [main, master] +permissions: {} + jobs: build-and-test: runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v6 @@ -66,6 +70,8 @@ jobs: docker: runs-on: ubuntu-latest needs: build-and-test + permissions: + contents: read steps: - uses: actions/checkout@v6 @@ -84,6 +90,7 @@ jobs: needs: build-and-test if: always() runs-on: ubuntu-latest + permissions: {} steps: - name: Finalize Coveralls uses: coverallsapp/github-action@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57c45a9..65feba2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,9 +5,7 @@ on: tags: - "v[0-9]+.[0-9]+.[0-9]+*" -permissions: - contents: write - packages: write +permissions: {} env: REGISTRY: ghcr.io @@ -16,6 +14,8 @@ env: jobs: test: runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v6 @@ -48,6 +48,9 @@ jobs: docker: needs: test runs-on: ubuntu-latest + permissions: + contents: read + packages: write steps: - uses: actions/checkout@v6 @@ -87,7 +90,7 @@ jobs: with: push: true context: . - platforms: linux/amd64,linux/arm64,linux/arm/v7 + platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | @@ -100,6 +103,8 @@ jobs: binaries: needs: test runs-on: ubuntu-latest + permissions: + contents: write strategy: fail-fast: false matrix: @@ -108,17 +113,10 @@ jobs: goarch: amd64 - goos: linux goarch: arm64 - - goos: linux - goarch: arm - goarm: "7" - - goos: linux - goarch: "386" - goos: darwin goarch: amd64 - goos: darwin goarch: arm64 - - goos: freebsd - goarch: amd64 steps: - uses: actions/checkout@v6 @@ -142,17 +140,17 @@ jobs: CGO_ENABLED: "0" GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} - GOARM: ${{ matrix.goarm }} run: | - BINARY_NAME="wireguard-ui-${{ matrix.goos }}-${{ matrix.goarch }}" - if [ -n "${{ matrix.goarm }}" ]; then - BINARY_NAME="${BINARY_NAME}v${{ matrix.goarm }}" - fi - go build -ldflags="-X 'main.appVersion=${{ github.ref_name }}' -X 'main.buildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)' -X 'main.gitCommit=${{ github.sha }}'" \ - -o "${BINARY_NAME}" . - tar czf "${BINARY_NAME}.tar.gz" "${BINARY_NAME}" init.sh + ARCHIVE="wireguard-ui-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz" + go build -trimpath \ + -ldflags="-s -w -X 'main.appVersion=${{ github.ref_name }}' -X 'main.buildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)' -X 'main.gitCommit=${{ github.sha }}'" \ + -o wireguard-ui . + tar czf "${ARCHIVE}" wireguard-ui + sha256sum "${ARCHIVE}" >> checksums.txt - - name: Upload release asset + - name: Upload release assets uses: softprops/action-gh-release@v3 with: - files: wireguard-ui-*.tar.gz + files: | + wireguard-ui-*.tar.gz + checksums.txt diff --git a/Dockerfile b/Dockerfile index b0dd183..2bf908b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,8 +32,9 @@ COPY . . COPY --from=frontend /src/assets ./assets/ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \ + -trimpath \ -ldflags="-s -w -X 'main.appVersion=${APP_VERSION}' -X 'main.buildTime=${BUILD_TIME}' -X 'main.gitCommit=${GIT_COMMIT}'" \ - -a -o wg-ui . + -o wg-ui . # Stage 3: Runtime FROM alpine:3.23 diff --git a/Makefile b/Makefile index 82b8215..b86c48c 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ GO_PACKAGES := $(shell go list ./... | grep -v 'wireguard-ui$$' | grep -v node_m VERSION ?= dev GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "N/A") BUILD_TIME := $(shell date -u '+%Y-%m-%d %H:%M:%S') -LDFLAGS := -X 'main.appVersion=$(VERSION)' -X 'main.buildTime=$(BUILD_TIME)' -X 'main.gitCommit=$(GIT_COMMIT)' +LDFLAGS := -s -w -X 'main.appVersion=$(VERSION)' -X 'main.buildTime=$(BUILD_TIME)' -X 'main.gitCommit=$(GIT_COMMIT)' .PHONY: help build build-frontend build-backend test test-verbose coverage lint lint-go lint-frontend fmt vet clean dev @@ -20,7 +20,7 @@ build-frontend: ## Build the React frontend npm ci && npm run build build-backend: ## Build the Go binary - CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o $(APP_NAME) . + CGO_ENABLED=0 go build -trimpath -ldflags="$(LDFLAGS)" -o $(APP_NAME) . ## ---- Test ---- diff --git a/README.md b/README.md index e863ea5..fcf3963 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,26 @@ # digitaltolk/wireguard-ui +[](https://github.com/DigitalTolk/wireguard-ui/actions/workflows/ci.yml) [](https://coveralls.io/github/DigitalTolk/wireguard-ui) -A web user interface to manage your WireGuard setup. +A modern web interface to manage your WireGuard VPN setup. -Fork of [ngoduykhanh/wireguard-ui](https://github.com/ngoduykhanh/wireguard-ui). +Fork of [ngoduykhanh/wireguard-ui](https://github.com/ngoduykhanh/wireguard-ui) by [Khanh Ngo](https://github.com/ngoduykhanh). ## Features -- Modern React frontend built with shadcn/ui components (WCAG accessible) -- Single Sign-On via OpenID Connect (OIDC) -- Audit logging of all administrative actions -- SQLite database backend -- Client management with QR codes, email delivery, and Excel export -- Multi-platform Docker images (amd64, arm64, armv7) +- Modern React frontend with [shadcn/ui](https://ui.shadcn.com/) components +- WCAG accessible, auto dark mode, mobile-friendly +- Single Sign-On via OpenID Connect (Microsoft Entra ID / any OIDC provider) +- Audit logging with Excel export (ISO 27001 evidence) +- SQLite database (pure Go, no CGO) +- Client management: QR codes, config download, email delivery +- Server-side search and filtering with bookmarkable URLs +- Input validation (frontend + backend) +- Multi-platform Docker images (linux/amd64, linux/arm64) ## Quick Start -### Docker - -```sh -docker pull digitaltolk/wireguard-ui -``` - ### Docker Compose ```yaml @@ -34,51 +32,156 @@ services: - NET_ADMIN network_mode: host environment: - - WGUI_USERNAME=admin - - WGUI_PASSWORD=admin + - OIDC_ISSUER_URL=https://login.microsoftonline.com/{tenant-id}/v2.0 + - OIDC_CLIENT_ID=your-app-client-id + - OIDC_CLIENT_SECRET=your-app-client-secret + - OIDC_REDIRECT_URL=https://vpn.example.com/api/v1/auth/oidc/callback + - SESSION_SECRET=change-me-to-a-random-string - WGUI_MANAGE_START=true - WGUI_MANAGE_RESTART=true volumes: - ./db:/app/db - /etc/wireguard:/etc/wireguard + restart: unless-stopped ``` -> The default username and password are `admin`. Change them to secure your setup. +### Docker -See [`examples/docker-compose`](examples/docker-compose) for more configurations -(LinuxServer, BoringTun, host networking). +```sh +docker pull digitaltolk/wireguard-ui:latest +# or from GitHub Container Registry: +docker pull ghcr.io/digitaltolk/wireguard-ui:latest +``` ### Binary -Download the binary from the [Releases](https://github.com/DigitalTolk/wireguard-ui/releases) page: +Download from [Releases](https://github.com/DigitalTolk/wireguard-ui/releases): ```sh -./wireguard-ui +tar xzf wireguard-ui-linux-amd64.tar.gz -C /usr/local/bin +wireguard-ui ``` ## Environment Variables -Refer to the environment variable tables in the upstream documentation or inspect -the source for a full list. Key variables: +### Application | Variable | Description | Default | |---|---|---| -| `BIND_ADDRESS` | Listen address | `0.0.0.0:80` | -| `SESSION_SECRET` | Secret for session cookies | N/A | -| `WGUI_USERNAME` | Initial admin username | `admin` | -| `WGUI_PASSWORD` | Initial admin password | `admin` | -| `WGUI_CONFIG_FILE_PATH` | WireGuard config path | `/etc/wireguard/wg0.conf` | -| `WGUI_MANAGE_START` | Start WireGuard with container | `false` | -| `WGUI_MANAGE_RESTART` | Restart WireGuard on config apply | `false` | +| `BIND_ADDRESS` | Listen address and port | `0.0.0.0:5000` | +| `BASE_PATH` | URL base path (for reverse proxy) | `` | +| `SESSION_SECRET` | Secret key for session cookies | random | +| `SESSION_SECRET_FILE` | File containing session secret | | +| `SESSION_MAX_DURATION` | Max session lifetime in days | `90` | +| `DISABLE_LOGIN` | Disable authentication (development only) | `false` | +| `WGUI_LOG_LEVEL` | Log level: DEBUG, INFO, WARN, ERROR, OFF | `INFO` | +| `WGUI_FAVICON_FILE_PATH` | Custom favicon file path | | -## Build +### OIDC / SSO (required for production) + +| Variable | Description | Default | +|---|---|---| +| `OIDC_ISSUER_URL` | OIDC provider URL (e.g. `https://login.microsoftonline.com/{tenant}/v2.0`) | | +| `OIDC_CLIENT_ID` | OAuth2 client ID | | +| `OIDC_CLIENT_SECRET` | OAuth2 client secret | | +| `OIDC_CLIENT_SECRET_FILE` | File containing client secret | | +| `OIDC_REDIRECT_URL` | Callback URL (e.g. `https://vpn.example.com/api/v1/auth/oidc/callback`) | | +| `OIDC_SCOPES` | Comma-separated scopes | `openid,profile,email` | +| `OIDC_AUTO_PROVISION` | Auto-create users on first OIDC login | `true` | +| `OIDC_ADMIN_GROUPS` | Comma-separated group UUIDs for auto-admin | | + +### WireGuard Server + +| Variable | Description | Default | +|---|---|---| +| `WGUI_ENDPOINT_ADDRESS` | Public endpoint address for clients | auto-detected | +| `WGUI_SERVER_INTERFACE_ADDRESSES` | Server interface CIDR addresses | `10.252.1.0/24` | +| `WGUI_SERVER_LISTEN_PORT` | WireGuard listen port | `51820` | +| `WGUI_SERVER_POST_UP_SCRIPT` | Post-up script | | +| `WGUI_SERVER_POST_DOWN_SCRIPT` | Post-down script | | +| `WGUI_DNS` | DNS servers pushed to clients (comma-separated) | `1.1.1.1` | +| `WGUI_MTU` | MTU size | `1450` | +| `WGUI_PERSISTENT_KEEPALIVE` | Keepalive interval in seconds | `15` | +| `WGUI_FIREWALL_MARK` | Firewall mark (hex) | `0xca6c` | +| `WGUI_TABLE` | Routing table | `auto` | +| `WGUI_CONFIG_FILE_PATH` | Path to write wg0.conf | `/etc/wireguard/wg0.conf` | +| `WG_CONF_TEMPLATE` | Custom wg.conf template path | built-in | +| `SUBNET_RANGES` | Named subnet ranges (e.g. `LAN:10.0.0.0/24;REMOTE:192.168.0.0/24`) | | + +### Client Defaults + +| Variable | Description | Default | +|---|---|---| +| `WGUI_DEFAULT_CLIENT_ALLOWED_IPS` | Default allowed IPs for new clients | `0.0.0.0/0` | +| `WGUI_DEFAULT_CLIENT_EXTRA_ALLOWED_IPS` | Default extra allowed IPs | | +| `WGUI_DEFAULT_CLIENT_USE_SERVER_DNS` | Use server DNS by default | `true` | +| `WGUI_DEFAULT_CLIENT_ENABLE_AFTER_CREATION` | Enable client after creation | `true` | + +### Email (SMTP) + +| Variable | Description | Default | +|---|---|---| +| `SMTP_HOSTNAME` | SMTP server hostname | `127.0.0.1` | +| `SMTP_PORT` | SMTP server port | `25` | +| `SMTP_USERNAME` | SMTP username | | +| `SMTP_PASSWORD` | SMTP password | | +| `SMTP_PASSWORD_FILE` | File containing SMTP password | | +| `SMTP_HELO` | SMTP HELO hostname | `localhost` | +| `SMTP_ENCRYPTION` | NONE, SSL, SSLTLS, TLS, STARTTLS | `STARTTLS` | +| `SMTP_AUTH_TYPE` | PLAIN, LOGIN, NONE | `NONE` | +| `EMAIL_FROM_ADDRESS` | Sender email address | | +| `EMAIL_FROM_NAME` | Sender display name | `WireGuard UI` | + +### Email (SendGrid) + +| Variable | Description | Default | +|---|---|---| +| `SENDGRID_API_KEY` | SendGrid API key | | +| `SENDGRID_API_KEY_FILE` | File containing SendGrid API key | | + +### Container Management + +| Variable | Description | Default | +|---|---|---| +| `WGUI_MANAGE_START` | Start WireGuard when container starts | `false` | +| `WGUI_MANAGE_RESTART` | Restart WireGuard when config changes | `false` | + +## Development ```sh -# Build everything (frontend + Go binary) +# Install dependencies +make deps && make deps-frontend + +# Start Go backend (port 5000) +make dev + +# Start frontend dev server with hot reload (port 5173) +make dev-frontend + +# Run all tests +make test + +# Run linters +make lint + +# Build everything make build # Build Docker image -docker build -t wireguard-ui . +make docker-build +``` + +### Available Make Targets + +``` +make help Show all commands +make build Build frontend + Go binary +make test Run all tests with coverage +make lint Run Go + frontend linters +make dev Start Go backend +make dev-frontend Start Vite dev server +make docker-build Build Docker image +make coverage-html Open coverage report in browser ``` ## License diff --git a/emailer/emailer_test.go b/emailer/emailer_test.go index 954062b..ab4200c 100644 --- a/emailer/emailer_test.go +++ b/emailer/emailer_test.go @@ -80,14 +80,13 @@ func TestAddressField_WithoutName(t *testing.T) { // --- NewSmtpMail tests --- func TestNewSmtpMail(t *testing.T) { - s := NewSmtpMail("smtp.example.com", 587, "user", "pass", "helo.example.com", true, "PLAIN", "Sender", "sender@example.com", "TLS") + s := NewSmtpMail("smtp.example.com", 587, "user", "pass", "helo.example.com", "PLAIN", "Sender", "sender@example.com", "TLS") assert.Equal(t, "smtp.example.com", s.hostname) assert.Equal(t, 587, s.port) assert.Equal(t, "user", s.username) assert.Equal(t, "pass", s.password) assert.Equal(t, "helo.example.com", s.smtpHelo) - assert.True(t, s.noTLSCheck) assert.Equal(t, mail.AuthPlain, s.authType) assert.Equal(t, mail.EncryptionTLS, s.encryption) assert.Equal(t, "Sender", s.fromName) @@ -95,11 +94,10 @@ func TestNewSmtpMail(t *testing.T) { } func TestNewSmtpMail_Defaults(t *testing.T) { - s := NewSmtpMail("host", 25, "", "", "", false, "", "", "from@test.com", "") + s := NewSmtpMail("host", 25, "", "", "", "", "", "from@test.com", "") assert.Equal(t, mail.AuthNone, s.authType) assert.Equal(t, mail.EncryptionSTARTTLS, s.encryption) - assert.False(t, s.noTLSCheck) } // --- NewSendgridApiMail tests --- @@ -123,14 +121,14 @@ func TestNewSendgridApiMail_Empty(t *testing.T) { // --- SmtpMail.Send tests (connection error path) --- func TestSmtpMail_Send_ConnectionError(t *testing.T) { - s := NewSmtpMail("127.0.0.1", 1, "user", "pass", "", false, "PLAIN", "Sender", "from@test.com", "NONE") + s := NewSmtpMail("127.0.0.1", 1, "user", "pass", "", "PLAIN", "Sender", "from@test.com", "NONE") err := s.Send("Recipient", "to@test.com", "Subject", "
Body
", nil) assert.Error(t, err, "Send should fail when SMTP server is unreachable") } -func TestSmtpMail_Send_ConnectionError_WithTLSCheck(t *testing.T) { - s := NewSmtpMail("127.0.0.1", 1, "", "", "helo.test", true, "LOGIN", "From Name", "from@test.com", "SSL") +func TestSmtpMail_Send_ConnectionError_SSL(t *testing.T) { + s := NewSmtpMail("127.0.0.1", 1, "", "", "helo.test", "LOGIN", "From Name", "from@test.com", "SSL") err := s.Send("Recipient", "to@test.com", "Subject", "Body", []Attachment{ {Name: "test.txt", Data: []byte("hello")}, @@ -139,7 +137,7 @@ func TestSmtpMail_Send_ConnectionError_WithTLSCheck(t *testing.T) { } func TestSmtpMail_Send_ConnectionError_STARTTLS(t *testing.T) { - s := NewSmtpMail("127.0.0.1", 1, "", "", "", false, "", "", "from@test.com", "STARTTLS") + s := NewSmtpMail("127.0.0.1", 1, "", "", "", "", "", "from@test.com", "STARTTLS") err := s.Send("", "to@test.com", "Test", "Content", nil) assert.Error(t, err) diff --git a/emailer/smtp.go b/emailer/smtp.go index 2586924..a36db00 100644 --- a/emailer/smtp.go +++ b/emailer/smtp.go @@ -1,7 +1,6 @@ package emailer import ( - "crypto/tls" "fmt" "strings" "time" @@ -17,7 +16,6 @@ type SmtpMail struct { smtpHelo string authType mail.AuthType encryption mail.Encryption - noTLSCheck bool fromName string from string } @@ -48,8 +46,8 @@ func encryptionType(encryptionType string) mail.Encryption { } } -func NewSmtpMail(hostname string, port int, username string, password string, SmtpHelo string, noTLSCheck bool, auth string, fromName, from string, encryption string) *SmtpMail { - ans := SmtpMail{hostname: hostname, port: port, username: username, password: password, smtpHelo: SmtpHelo, noTLSCheck: noTLSCheck, fromName: fromName, from: from, authType: authType(auth), encryption: encryptionType(encryption)} +func NewSmtpMail(hostname string, port int, username string, password string, SmtpHelo string, auth string, fromName, from string, encryption string) *SmtpMail { + ans := SmtpMail{hostname: hostname, port: port, username: username, password: password, smtpHelo: SmtpHelo, fromName: fromName, from: from, authType: authType(auth), encryption: encryptionType(encryption)} return &ans } @@ -74,10 +72,6 @@ func (o *SmtpMail) Send(toName string, to string, subject string, content string server.ConnectTimeout = 10 * time.Second server.SendTimeout = 10 * time.Second - if o.noTLSCheck { - server.TLSConfig = &tls.Config{InsecureSkipVerify: true} - } - smtpClient, err := server.Connect() if err != nil { diff --git a/examples/docker-compose/boringtun.yml b/examples/docker-compose/boringtun.yml index db3ae15..e594f60 100644 --- a/examples/docker-compose/boringtun.yml +++ b/examples/docker-compose/boringtun.yml @@ -1,12 +1,9 @@ -version: "3" - services: boringtun: image: ghcr.io/ntkme/boringtun:edge command: - wg0 container_name: boringtun - # use the network of the 'wireguard-ui' service. this enables to show active clients in the status page network_mode: service:wireguard-ui cap_add: - NET_ADMIN @@ -20,12 +17,14 @@ services: cap_add: - NET_ADMIN environment: + - OIDC_ISSUER_URL=https://login.microsoftonline.com/{tenant-id}/v2.0 + - OIDC_CLIENT_ID=your-client-id + - OIDC_CLIENT_SECRET=your-client-secret + - OIDC_REDIRECT_URL=https://vpn.example.com/api/v1/auth/oidc/callback + - SESSION_SECRET=change-me - SENDGRID_API_KEY - EMAIL_FROM_ADDRESS - EMAIL_FROM_NAME - - SESSION_SECRET - - WGUI_USERNAME=admin - - WGUI_PASSWORD=admin - WG_CONF_TEMPLATE - WGUI_MANAGE_START=true - WGUI_MANAGE_RESTART=true @@ -37,7 +36,6 @@ services: - ./db:/app/db - ./config:/etc/wireguard ports: - # port for wireguard-ui - "5000:5000" - # port of the wireguard server. this must be set here as the `boringtun` container joins the network of this container and hasn't its own network over which it could publish the ports - "51820:51820/udp" + restart: unless-stopped diff --git a/examples/docker-compose/linuxserver.yml b/examples/docker-compose/linuxserver.yml index 09a59aa..0891729 100644 --- a/examples/docker-compose/linuxserver.yml +++ b/examples/docker-compose/linuxserver.yml @@ -1,5 +1,3 @@ -version: "3" - services: wireguard: image: linuxserver/wireguard:latest @@ -9,9 +7,7 @@ services: volumes: - ./config:/config ports: - # port for wireguard-ui. this must be set here as the `wireguard-ui` container joins the network of this container and hasn't its own network over which it could publish the ports - "5000:5000" - # port of the wireguard server - "51820:51820/udp" wireguard-ui: @@ -21,15 +17,16 @@ services: - wireguard cap_add: - NET_ADMIN - # use the network of the 'wireguard' service. this enables to show active clients in the status page network_mode: service:wireguard environment: + - OIDC_ISSUER_URL=https://login.microsoftonline.com/{tenant-id}/v2.0 + - OIDC_CLIENT_ID=your-client-id + - OIDC_CLIENT_SECRET=your-client-secret + - OIDC_REDIRECT_URL=https://vpn.example.com/api/v1/auth/oidc/callback + - SESSION_SECRET=change-me - SENDGRID_API_KEY - EMAIL_FROM_ADDRESS - EMAIL_FROM_NAME - - SESSION_SECRET - - WGUI_USERNAME=admin - - WGUI_PASSWORD=admin - WG_CONF_TEMPLATE - WGUI_MANAGE_START=true - WGUI_MANAGE_RESTART=true @@ -40,3 +37,4 @@ services: volumes: - ./db:/app/db - ./config:/etc/wireguard + restart: unless-stopped diff --git a/examples/docker-compose/system.yml b/examples/docker-compose/system.yml index 57ddca5..40af4ce 100644 --- a/examples/docker-compose/system.yml +++ b/examples/docker-compose/system.yml @@ -1,23 +1,22 @@ -version: "3" - services: wireguard-ui: image: digitaltolk/wireguard-ui:latest container_name: wireguard-ui cap_add: - NET_ADMIN - # required to show active clients. with this set, you don't need to expose the ui port (5000) anymore network_mode: host environment: + - OIDC_ISSUER_URL=https://login.microsoftonline.com/{tenant-id}/v2.0 + - OIDC_CLIENT_ID=your-client-id + - OIDC_CLIENT_SECRET=your-client-secret + - OIDC_REDIRECT_URL=https://vpn.example.com/api/v1/auth/oidc/callback + - SESSION_SECRET=change-me - SENDGRID_API_KEY - EMAIL_FROM_ADDRESS - EMAIL_FROM_NAME - - SESSION_SECRET - - WGUI_USERNAME=admin - - WGUI_PASSWORD=admin - WG_CONF_TEMPLATE - - WGUI_MANAGE_START=false - - WGUI_MANAGE_RESTART=false + - WGUI_MANAGE_START=true + - WGUI_MANAGE_RESTART=true logging: driver: json-file options: @@ -25,3 +24,4 @@ services: volumes: - ./db:/app/db - /etc/wireguard:/etc/wireguard + restart: unless-stopped diff --git a/go.mod b/go.mod index b2be99a..8e5765b 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/sendgrid/sendgrid-go v3.16.1+incompatible github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/xhit/go-simple-mail/v2 v2.16.0 - golang.org/x/crypto v0.50.0 + golang.org/x/crypto v0.50.0 // indirect //golang.zx2c4.com/wireguard v0.0.20200121 // indirect golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 gopkg.in/go-playground/validator.v9 v9.31.0 diff --git a/handler/api_v1_auth_test.go b/handler/api_v1_auth_test.go index 409648f..7afba0e 100644 --- a/handler/api_v1_auth_test.go +++ b/handler/api_v1_auth_test.go @@ -5,11 +5,13 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/labstack/echo/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/DigitalTolk/wireguard-ui/model" "github.com/DigitalTolk/wireguard-ui/util" ) @@ -176,6 +178,10 @@ func TestAPIGetMe_WithSession(t *testing.T) { env := setupTestEnv(t) util.DisableLogin = false + // Create the user that the session will reference + now := time.Now().UTC() + env.db.SaveUser(model.User{Username: "admin", Email: "admin@test.com", Admin: true, CreatedAt: now, UpdatedAt: now}) + // Create a route that first creates a session and then calls APIGetMe env.echo.GET("/setup-and-getme", func(c echo.Context) error { // Create a session for admin user diff --git a/handler/api_v1_oidc_test.go b/handler/api_v1_oidc_test.go index 58bd940..940ac5f 100644 --- a/handler/api_v1_oidc_test.go +++ b/handler/api_v1_oidc_test.go @@ -130,6 +130,10 @@ func TestFindOrCreateOIDCUser_NewUser_AdminViaGroups(t *testing.T) { func TestFindOrCreateOIDCUser_NewUser_NotAdmin(t *testing.T) { env := setupTestEnv(t) + now := time.Now().UTC() + + // pre-create a user so the next one isn't the first (first user auto-gets admin) + env.db.SaveUser(model.User{Username: "existing", OIDCSub: "sub-existing", Admin: true, CreatedAt: now, UpdatedAt: now}) origAutoProvision := util.OIDCAutoProvision util.OIDCAutoProvision = true diff --git a/main.go b/main.go index bcd6a09..76a318b 100644 --- a/main.go +++ b/main.go @@ -39,7 +39,6 @@ var ( flagSmtpUsername string flagSmtpPassword string flagSmtpAuthType = "NONE" - flagSmtpNoTLSCheck = false flagSmtpEncryption = "STARTTLS" flagSmtpHelo = "localhost" flagSendgridApiKey string @@ -78,7 +77,6 @@ func init() { flag.IntVar(&flagSmtpPort, "smtp-port", util.LookupEnvOrInt("SMTP_PORT", flagSmtpPort), "SMTP Port") flag.StringVar(&flagSmtpHelo, "smtp-helo", util.LookupEnvOrString("SMTP_HELO", flagSmtpHelo), "SMTP HELO Hostname") flag.StringVar(&flagSmtpUsername, "smtp-username", util.LookupEnvOrString("SMTP_USERNAME", flagSmtpUsername), "SMTP Username") - flag.BoolVar(&flagSmtpNoTLSCheck, "smtp-no-tls-check", util.LookupEnvOrBool("SMTP_NO_TLS_CHECK", flagSmtpNoTLSCheck), "Disable TLS verification for SMTP. This is potentially dangerous.") flag.StringVar(&flagSmtpEncryption, "smtp-encryption", util.LookupEnvOrString("SMTP_ENCRYPTION", flagSmtpEncryption), "SMTP Encryption : NONE, SSL, SSLTLS, TLS or STARTTLS (by default)") flag.StringVar(&flagSmtpAuthType, "smtp-auth-type", util.LookupEnvOrString("SMTP_AUTH_TYPE", flagSmtpAuthType), "SMTP Auth Type : PLAIN, LOGIN or NONE.") flag.StringVar(&flagEmailFrom, "email-from", util.LookupEnvOrString("EMAIL_FROM_ADDRESS", flagEmailFrom), "'From' email address.") @@ -122,7 +120,6 @@ func init() { util.SmtpUsername = flagSmtpUsername util.SmtpPassword = flagSmtpPassword util.SmtpAuthType = flagSmtpAuthType - util.SmtpNoTLSCheck = flagSmtpNoTLSCheck util.SmtpEncryption = flagSmtpEncryption util.SendgridApiKey = flagSendgridApiKey util.EmailFrom = flagEmailFrom @@ -214,7 +211,7 @@ func main() { if util.SendgridApiKey != "" { sendmail = emailer.NewSendgridApiMail(util.SendgridApiKey, util.EmailFromName, util.EmailFrom) } else { - sendmail = emailer.NewSmtpMail(util.SmtpHostname, util.SmtpPort, util.SmtpUsername, util.SmtpPassword, util.SmtpHelo, util.SmtpNoTLSCheck, util.SmtpAuthType, util.EmailFromName, util.EmailFrom, util.SmtpEncryption) + sendmail = emailer.NewSmtpMail(util.SmtpHostname, util.SmtpPort, util.SmtpUsername, util.SmtpPassword, util.SmtpHelo, util.SmtpAuthType, util.EmailFromName, util.EmailFrom, util.SmtpEncryption) } // set up Echo with session middleware diff --git a/model/user.go b/model/user.go index 1511b13..548af20 100644 --- a/model/user.go +++ b/model/user.go @@ -4,14 +4,11 @@ import "time" // User model type User struct { - Username string `json:"username"` - Password string `json:"password,omitempty"` - // PasswordHash takes precedence over Password. - PasswordHash string `json:"password_hash,omitempty"` - Email string `json:"email"` - DisplayName string `json:"display_name"` - OIDCSub string `json:"oidc_sub,omitempty"` - Admin bool `json:"admin"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Username string `json:"username"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + OIDCSub string `json:"oidc_sub,omitempty"` + Admin bool `json:"admin"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/store/sqlitedb/sqlitedb.go b/store/sqlitedb/sqlitedb.go index 2bc1241..1ad15fe 100644 --- a/store/sqlitedb/sqlitedb.go +++ b/store/sqlitedb/sqlitedb.go @@ -134,23 +134,7 @@ func (o *SqliteDB) Init() error { o.db.Exec(`INSERT INTO hashes (id, client, server) VALUES (1, 'none', 'none')`) } - // default user - var userCount int - o.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&userCount) - if userCount == 0 { - username := util.LookupEnvOrString(util.UsernameEnvVar, util.DefaultUsername) - now := time.Now().UTC() - - _, err := o.db.Exec( - `INSERT INTO users (username, admin, created_at, updated_at) VALUES (?, ?, ?, ?)`, - username, true, now, now, - ) - if err != nil { - return fmt.Errorf("cannot create default user: %w", err) - } - } - - // init caches + // init caches (first OIDC login auto-provisions admin user) users, err := o.GetUsers() if err == nil { util.DBUsersToCRC32Mutex.Lock() diff --git a/store/sqlitedb/sqlitedb_test.go b/store/sqlitedb/sqlitedb_test.go index 69b1487..795cd13 100644 --- a/store/sqlitedb/sqlitedb_test.go +++ b/store/sqlitedb/sqlitedb_test.go @@ -423,10 +423,10 @@ func TestInit_CreatesDefaults(t *testing.T) { err := db.Init() require.NoError(t, err) - // should have created default user + // no default user — first OIDC login creates admin users, err := db.GetUsers() require.NoError(t, err) - assert.GreaterOrEqual(t, len(users), 1) + assert.Len(t, users, 0) // should have created server config server, err := db.GetServer() diff --git a/util/config.go b/util/config.go index 038649d..fb730a7 100644 --- a/util/config.go +++ b/util/config.go @@ -15,7 +15,6 @@ var ( SmtpPort int SmtpUsername string SmtpPassword string - SmtpNoTLSCheck bool SmtpEncryption string SmtpAuthType string SmtpHelo string @@ -40,9 +39,6 @@ var ( ) const ( - DefaultUsername = "admin" - DefaultPassword = "admin" - DefaultIsAdmin = true DefaultServerAddress = "10.252.1.0/24" DefaultServerPort = 51820 DefaultDNS = "1.1.1.1" @@ -51,11 +47,6 @@ const ( DefaultFirewallMark = "0xca6c" // i.e. 51820 DefaultTable = "auto" DefaultConfigFilePath = "/etc/wireguard/wg0.conf" - UsernameEnvVar = "WGUI_USERNAME" - PasswordEnvVar = "WGUI_PASSWORD" - PasswordFileEnvVar = "WGUI_PASSWORD_FILE" - PasswordHashEnvVar = "WGUI_PASSWORD_HASH" - PasswordHashFileEnvVar = "WGUI_PASSWORD_HASH_FILE" FaviconFilePathEnvVar = "WGUI_FAVICON_FILE_PATH" EndpointAddressEnvVar = "WGUI_ENDPOINT_ADDRESS" DNSEnvVar = "WGUI_DNS" diff --git a/util/hash.go b/util/hash.go deleted file mode 100644 index 3733451..0000000 --- a/util/hash.go +++ /dev/null @@ -1,32 +0,0 @@ -package util - -import ( - "encoding/base64" - "errors" - "fmt" - - "golang.org/x/crypto/bcrypt" -) - -func HashPassword(plaintext string) (string, error) { - bytes, err := bcrypt.GenerateFromPassword([]byte(plaintext), 14) - if err != nil { - return "", fmt.Errorf("cannot hash password: %w", err) - } - return base64.StdEncoding.EncodeToString(bytes), nil -} - -func VerifyHash(base64Hash string, plaintext string) (bool, error) { - hash, err := base64.StdEncoding.DecodeString(base64Hash) - if err != nil { - return false, fmt.Errorf("cannot decode base64 hash: %w", err) - } - err = bcrypt.CompareHashAndPassword(hash, []byte(plaintext)) - if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { - return false, nil - } - if err != nil { - return false, fmt.Errorf("cannot verify password: %w", err) - } - return true, nil -} diff --git a/util/hash_test.go b/util/hash_test.go deleted file mode 100644 index 046c58b..0000000 --- a/util/hash_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package util - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestHashPassword(t *testing.T) { - hash, err := HashPassword("mypassword") - require.NoError(t, err) - assert.NotEmpty(t, hash) - - // hash is base64 encoded - assert.Greater(t, len(hash), 20) - - // different calls produce different hashes (bcrypt salt) - hash2, err := HashPassword("mypassword") - require.NoError(t, err) - assert.NotEqual(t, hash, hash2) -} - -func TestVerifyHash(t *testing.T) { - hash, err := HashPassword("testpassword") - require.NoError(t, err) - - match, err := VerifyHash(hash, "testpassword") - assert.NoError(t, err) - assert.True(t, match) - - match, err = VerifyHash(hash, "wrongpassword") - assert.NoError(t, err) - assert.False(t, match) -} - -func TestVerifyHash_InvalidBase64(t *testing.T) { - _, err := VerifyHash("not-base64!!!", "password") - assert.Error(t, err) -} - -func TestVerifyHash_InvalidBcryptHash(t *testing.T) { - // Valid base64 but not a valid bcrypt hash - _, err := VerifyHash("aGVsbG93b3JsZA==", "password") - assert.Error(t, err) -} - -func TestHashPassword_EmptyPassword(t *testing.T) { - hash, err := HashPassword("") - require.NoError(t, err) - assert.NotEmpty(t, hash) -}