Compare commits

..

5 Commits

12 changed files with 248 additions and 37 deletions

View File

@@ -2,6 +2,7 @@ shell := /bin/sh
export VERSION ?= $(shell git describe --tags --abbrev=0)
export BUILD_DATE ?= $(shell date -u +'%Y%m%d-%H%M')
export GOOS = linux
export GOARCH ?= amd64
WEBUI_DIR ?= ../godoxy-frontend
DOCS_DIR ?= ../godoxy-wiki
@@ -113,9 +114,11 @@ build:
run:
cd ${PWD} && [ -f .env ] && godotenv -f .env go run ${BUILD_FLAGS} ./cmd
debug:
make NAME="godoxy-test" debug=1 build
sh -c 'HTTP_ADDR=:81 HTTPS_ADDR=:8443 API_ADDR=:8899 DEBUG=1 bin/godoxy-test'
dev:
docker compose -f dev.compose.yml up -t 0 -d
dev-build: build
docker compose -f dev.compose.yml up -t 0 -d --build
mtrace:
${BIN_PATH} debug-ls-mtrace > mtrace.json

33
dev.Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# Stage 1: deps
FROM golang:1.25.0-alpine AS deps
HEALTHCHECK NONE
# package version does not matter
# trunk-ignore(hadolint/DL3018)
RUN apk add --no-cache tzdata make libcap-setcap
# Stage 3: Final image
FROM alpine:3.22
LABEL maintainer="yusing@6uo.me"
LABEL proxy.exclude=1
# copy timezone data
COPY --from=deps /usr/share/zoneinfo /usr/share/zoneinfo
# copy certs
COPY --from=deps /etc/ssl/certs /etc/ssl/certs
ARG TARGET
ENV TARGET=${TARGET}
ENV DOCKER_HOST=unix:///var/run/docker.sock
# copy binary
COPY bin/${TARGET} /app/run
WORKDIR /app
RUN chown -R 1000:1000 /app
CMD ["/app/run"]

44
dev.compose.yml Normal file
View File

@@ -0,0 +1,44 @@
services:
app:
image: godoxy-dev
build:
context: .
dockerfile: dev.Dockerfile
args:
- TARGET=godoxy
container_name: godoxy-proxy-dev
restart: unless-stopped
environment:
TZ: Asia/Hong_Kong
API_ADDR: :8999
API_USER: dev
API_PASSWORD: 1234
API_SKIP_ORIGIN_CHECK: true
API_JWT_SECURE: false
API_JWT_TTL: 24h
DEBUG: true
API_SECRET: 1234567891234567
labels:
proxy.exclude: true
proxy.#1.healthcheck.disable: true
ipc: host
network_mode: host
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./dev-data/config:/app/config
- ./dev-data/certs:/app/certs
- ./dev-data/error_pages:/app/error_pages:ro
- ./dev-data/data:/app/data
- ./dev-data/logs:/app/logs
depends_on:
- tinyauth
tinyauth:
image: ghcr.io/steveiliop56/tinyauth:v3
container_name: tinyauth
restart: unless-stopped
environment:
- SECRET=12345678912345671234567891234567
- APP_URL=https://tinyauth.my.app
- USERS=user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u # user:password
labels:
proxy.tinyauth.port: "3000"

View File

@@ -130,7 +130,7 @@ func (auth *OIDCProvider) setSessionTokenCookie(w http.ResponseWriter, r *http.R
log.Err(err).Msg("failed to sign session token")
return
}
SetTokenCookie(w, r, CookieOauthSessionToken, signed, common.APIJWTTokenTTL)
SetTokenCookie(w, r, auth.getAppScopedCookieName(CookieOauthSessionToken), signed, common.APIJWTTokenTTL)
}
func (auth *OIDCProvider) parseSessionJWT(sessionJWT string) (claims *sessionClaims, valid bool, err error) {

View File

@@ -3,6 +3,7 @@ package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
@@ -39,12 +40,27 @@ type (
var _ Provider = (*OIDCProvider)(nil)
// Cookie names for OIDC authentication
const (
CookieOauthState = "godoxy_oidc_state"
CookieOauthToken = "godoxy_oauth_token" //nolint:gosec
CookieOauthSessionToken = "godoxy_session_token" //nolint:gosec
)
// getAppScopedCookieName returns a cookie name scoped to the specific application
// to prevent conflicts between different OIDC clients
func (auth *OIDCProvider) getAppScopedCookieName(baseName string) string {
// Use the client ID to scope the cookie name
// This prevents conflicts when multiple apps use different client IDs
if auth.oauthConfig.ClientID != "" {
// Create a hash of the client ID to keep cookie names short
hash := sha256.Sum256([]byte(auth.oauthConfig.ClientID))
clientHash := base64.URLEncoding.EncodeToString(hash[:])[:8]
return fmt.Sprintf("%s_%s", baseName, clientHash)
}
return baseName
}
const (
OIDCAuthInitPath = "/"
OIDCPostAuthPath = "/auth/callback"
@@ -117,6 +133,37 @@ func NewOIDCProviderFromEnv() (*OIDCProvider, error) {
)
}
// NewOIDCProviderWithCustomClient creates a new OIDCProvider with custom client credentials
// based on an existing provider (for issuer discovery)
func NewOIDCProviderWithCustomClient(baseProvider *OIDCProvider, clientID, clientSecret string) (*OIDCProvider, error) {
if clientID == "" || clientSecret == "" {
return nil, errors.New("client ID and client secret are required")
}
// Create a new OIDC verifier with the custom client ID
oidcVerifier := baseProvider.oidcProvider.Verifier(&oidc.Config{
ClientID: clientID,
})
// Create new OAuth config with custom credentials
oauthConfig := &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: "",
Endpoint: baseProvider.oauthConfig.Endpoint,
Scopes: baseProvider.oauthConfig.Scopes,
}
return &OIDCProvider{
oauthConfig: oauthConfig,
oidcProvider: baseProvider.oidcProvider,
oidcVerifier: oidcVerifier,
endSessionURL: baseProvider.endSessionURL,
allowedUsers: baseProvider.allowedUsers,
allowedGroups: baseProvider.allowedGroups,
}, nil
}
func (auth *OIDCProvider) SetAllowedUsers(users []string) {
auth.allowedUsers = users
}
@@ -125,6 +172,10 @@ func (auth *OIDCProvider) SetAllowedGroups(groups []string) {
auth.allowedGroups = groups
}
func (auth *OIDCProvider) SetScopes(scopes []string) {
auth.oauthConfig.Scopes = scopes
}
// optRedirectPostAuth returns an oauth2 option that sets the "redirect_uri"
// parameter of the authorization URL to the post auth path of the current
// request host.
@@ -169,7 +220,7 @@ var rateLimit = rate.NewLimiter(rate.Every(time.Second), 1)
func (auth *OIDCProvider) LoginHandler(w http.ResponseWriter, r *http.Request) {
// check for session token
sessionToken, err := r.Cookie(CookieOauthSessionToken)
sessionToken, err := r.Cookie(auth.getAppScopedCookieName(CookieOauthSessionToken))
if err == nil { // session token exists
result, err := auth.TryRefreshToken(r.Context(), sessionToken.Value)
// redirect back to where they requested
@@ -193,7 +244,7 @@ func (auth *OIDCProvider) LoginHandler(w http.ResponseWriter, r *http.Request) {
}
state := generateState()
SetTokenCookie(w, r, CookieOauthState, state, 300*time.Second)
SetTokenCookie(w, r, auth.getAppScopedCookieName(CookieOauthState), state, 300*time.Second)
// redirect user to Idp
url := auth.oauthConfig.AuthCodeURL(state, optRedirectPostAuth(r))
if IsFrontend(r) {
@@ -209,7 +260,8 @@ func parseClaims(idToken *oidc.IDToken) (*IDTokenClaims, error) {
if err := idToken.Claims(&claim); err != nil {
return nil, fmt.Errorf("failed to parse claims: %w", err)
}
if claim.Username == "" {
// Username is optional if groups are present
if claim.Username == "" && len(claim.Groups) == 0 {
return nil, errors.New("missing username in ID token")
}
return &claim, nil
@@ -228,7 +280,7 @@ func (auth *OIDCProvider) checkAllowed(user string, groups []string) bool {
}
func (auth *OIDCProvider) CheckToken(r *http.Request) error {
tokenCookie, err := r.Cookie(CookieOauthToken)
tokenCookie, err := r.Cookie(auth.getAppScopedCookieName(CookieOauthToken))
if err != nil {
return ErrMissingOAuthToken
}
@@ -257,7 +309,7 @@ func (auth *OIDCProvider) PostAuthCallbackHandler(w http.ResponseWriter, r *http
}
// verify state
state, err := r.Cookie(CookieOauthState)
state, err := r.Cookie(auth.getAppScopedCookieName(CookieOauthState))
if err != nil {
http.Error(w, "missing state cookie", http.StatusBadRequest)
return
@@ -297,8 +349,8 @@ func (auth *OIDCProvider) PostAuthCallbackHandler(w http.ResponseWriter, r *http
}
func (auth *OIDCProvider) LogoutHandler(w http.ResponseWriter, r *http.Request) {
oauthToken, _ := r.Cookie(CookieOauthToken)
sessionToken, _ := r.Cookie(CookieOauthSessionToken)
oauthToken, _ := r.Cookie(auth.getAppScopedCookieName(CookieOauthToken))
sessionToken, _ := r.Cookie(auth.getAppScopedCookieName(CookieOauthSessionToken))
auth.clearCookie(w, r)
if sessionToken != nil {
@@ -325,17 +377,17 @@ func (auth *OIDCProvider) LogoutHandler(w http.ResponseWriter, r *http.Request)
}
func (auth *OIDCProvider) setIDTokenCookie(w http.ResponseWriter, r *http.Request, jwt string, ttl time.Duration) {
SetTokenCookie(w, r, CookieOauthToken, jwt, ttl)
SetTokenCookie(w, r, auth.getAppScopedCookieName(CookieOauthToken), jwt, ttl)
}
func (auth *OIDCProvider) clearCookie(w http.ResponseWriter, r *http.Request) {
ClearTokenCookie(w, r, CookieOauthToken)
ClearTokenCookie(w, r, CookieOauthSessionToken)
ClearTokenCookie(w, r, auth.getAppScopedCookieName(CookieOauthToken))
ClearTokenCookie(w, r, auth.getAppScopedCookieName(CookieOauthSessionToken))
}
// handleTestCallback handles OIDC callback in test environment.
func (auth *OIDCProvider) handleTestCallback(w http.ResponseWriter, r *http.Request) {
state, err := r.Cookie(CookieOauthState)
state, err := r.Cookie(auth.getAppScopedCookieName(CookieOauthState))
if err != nil {
http.Error(w, "missing state cookie", http.StatusBadRequest)
return
@@ -347,7 +399,7 @@ func (auth *OIDCProvider) handleTestCallback(w http.ResponseWriter, r *http.Requ
}
// Create test JWT token
SetTokenCookie(w, r, CookieOauthToken, "test", time.Hour)
SetTokenCookie(w, r, auth.getAppScopedCookieName(CookieOauthToken), "test", time.Hour)
http.Redirect(w, r, "/", http.StatusFound)
}

View File

@@ -426,6 +426,9 @@ func TestCheckToken(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
// Create the Auth Provider.
auth := &OIDCProvider{
oauthConfig: &oauth2.Config{
ClientID: clientID,
},
oidcVerifier: provider.verifier,
allowedUsers: tc.allowedUsers,
allowedGroups: tc.allowedGroups,
@@ -435,7 +438,7 @@ func TestCheckToken(t *testing.T) {
// Craft a test HTTP request that includes the token as a cookie.
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{
Name: CookieOauthToken,
Name: auth.getAppScopedCookieName(CookieOauthToken),
Value: signedToken,
})

View File

@@ -3,6 +3,7 @@ package middleware
import (
"errors"
"net/http"
"strings"
"sync"
"sync/atomic"
@@ -13,6 +14,9 @@ import (
type oidcMiddleware struct {
AllowedUsers []string `json:"allowed_users"`
AllowedGroups []string `json:"allowed_groups"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
Scopes string `json:"scopes"`
auth *auth.OIDCProvider
@@ -49,11 +53,28 @@ func (amw *oidcMiddleware) initSlow() error {
amw.initMu.Unlock()
}()
// Always start with the global OIDC provider (for issuer discovery)
authProvider, err := auth.NewOIDCProviderFromEnv()
if err != nil {
return err
}
// Check if custom client credentials are provided
if amw.ClientID != "" && amw.ClientSecret != "" {
// Use custom client credentials
customProvider, err := auth.NewOIDCProviderWithCustomClient(
authProvider,
amw.ClientID,
amw.ClientSecret,
)
if err != nil {
return err
}
authProvider = customProvider
}
// If no custom credentials, authProvider remains the global one
// Apply per-route user/group restrictions (these always override global)
if len(amw.AllowedUsers) > 0 {
authProvider.SetAllowedUsers(amw.AllowedUsers)
}
@@ -61,6 +82,11 @@ func (amw *oidcMiddleware) initSlow() error {
authProvider.SetAllowedGroups(amw.AllowedGroups)
}
// Apply custom scopes if provided
if amw.Scopes != "" {
authProvider.SetScopes(strings.Split(amw.Scopes, ","))
}
amw.auth = authProvider
return nil
}

View File

@@ -0,0 +1,35 @@
package middleware
import (
"testing"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
func TestOIDCMiddlewarePerRouteConfig(t *testing.T) {
t.Run("middleware struct has correct fields", func(t *testing.T) {
middleware := &oidcMiddleware{
AllowedUsers: []string{"custom-user"},
AllowedGroups: []string{"custom-group"},
ClientID: "custom-client-id",
ClientSecret: "custom-client-secret",
Scopes: "openid,profile,email,groups",
}
ExpectEqual(t, middleware.AllowedUsers, []string{"custom-user"})
ExpectEqual(t, middleware.AllowedGroups, []string{"custom-group"})
ExpectEqual(t, middleware.ClientID, "custom-client-id")
ExpectEqual(t, middleware.ClientSecret, "custom-client-secret")
ExpectEqual(t, middleware.Scopes, "openid,profile,email,groups")
})
t.Run("middleware struct handles empty values", func(t *testing.T) {
middleware := &oidcMiddleware{}
ExpectEqual(t, middleware.AllowedUsers, nil)
ExpectEqual(t, middleware.AllowedGroups, nil)
ExpectEqual(t, middleware.ClientID, "")
ExpectEqual(t, middleware.ClientSecret, "")
ExpectEqual(t, middleware.Scopes, "")
})
}

View File

@@ -179,13 +179,13 @@ func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent) {
_ = lb.Start(parent) // always return nil
linked = &ReveseProxyRoute{
Route: &Route{
Alias: cfg.Link,
Homepage: r.Homepage,
HealthMon: lb,
Alias: cfg.Link,
Homepage: r.Homepage,
},
loadBalancer: lb,
handler: lb,
}
linked.SetHealthMonitor(lb)
routes.HTTP.AddKey(cfg.Link, linked)
routes.All.AddKey(cfg.Link, linked)
r.task.OnFinished("remove_loadbalancer_route", func() {

View File

@@ -51,9 +51,6 @@ type (
Agent string `json:"agent,omitempty"`
Idlewatcher *types.IdlewatcherConfig `json:"idlewatcher,omitempty" extensions:"x-nullable"`
HealthMon types.HealthMonitor `json:"health,omitempty" swaggerignore:"true"`
// for swagger
HealthJSON *types.HealthJSON `json:",omitempty" form:"health"`
Metadata `deserialize:"-"`
}
@@ -70,6 +67,10 @@ type (
Excluded *bool `json:"excluded"`
HealthMon types.HealthMonitor `json:"health,omitempty" swaggerignore:"true"`
// for swagger
HealthJSON *types.HealthJSON `json:",omitempty" form:"health"`
impl types.Route
task *task.Task
@@ -271,11 +272,14 @@ func (r *Route) Task() *task.Task {
return r.task
}
func (r *Route) Start(parent task.Parent) (err gperr.Error) {
func (r *Route) Start(parent task.Parent) gperr.Error {
if r.lastError != nil {
return r.lastError
}
r.once.Do(func() {
err = r.start(parent)
r.lastError = r.start(parent)
})
return
return r.lastError
}
func (r *Route) start(parent task.Parent) gperr.Error {
@@ -466,7 +470,7 @@ func (r *Route) UseLoadBalance() bool {
}
func (r *Route) UseIdleWatcher() bool {
return r.Idlewatcher != nil && r.Idlewatcher.IdleTimeout > 0
return r.Idlewatcher != nil && r.Idlewatcher.IdleTimeout > 0 && r.Idlewatcher.ValErr() == nil
}
func (r *Route) UseHealthCheck() bool {
@@ -582,13 +586,11 @@ func (r *Route) Finalize() {
r.HealthCheck = types.DefaultHealthConfig()
}
if !r.HealthCheck.Disable {
if r.HealthCheck.Interval == 0 {
r.HealthCheck.Interval = common.HealthCheckIntervalDefault
}
if r.HealthCheck.Timeout == 0 {
r.HealthCheck.Timeout = common.HealthCheckTimeoutDefault
}
if r.HealthCheck.Interval == 0 {
r.HealthCheck.Interval = common.HealthCheckIntervalDefault
}
if r.HealthCheck.Timeout == 0 {
r.HealthCheck.Timeout = common.HealthCheckTimeoutDefault
}
}

View File

@@ -30,6 +30,8 @@ type (
StartEndpoint string `json:"start_endpoint,omitempty"` // Optional path that must be hit to start container
DependsOn []string `json:"depends_on,omitempty"`
valErr gperr.Error
} // @name IdlewatcherConfig
ContainerStopMethod string // @name ContainerStopMethod
ContainerSignal string // @name ContainerSignal
@@ -70,9 +72,10 @@ func (c *IdlewatcherConfig) ContainerName() string {
func (c *IdlewatcherConfig) Validate() gperr.Error {
if c.IdleTimeout == 0 { // zero idle timeout means no idle watcher
c.valErr = nil
return nil
}
errs := gperr.NewBuilder("idlewatcher config validation error")
errs := gperr.NewBuilder()
errs.AddRange(
c.validateProvider(),
c.validateTimeouts(),
@@ -80,7 +83,12 @@ func (c *IdlewatcherConfig) Validate() gperr.Error {
c.validateStopSignal(),
c.validateStartEndpoint(),
)
return errs.Error()
c.valErr = errs.Error()
return c.valErr
}
func (c *IdlewatcherConfig) ValErr() gperr.Error {
return c.valErr
}
func (c *IdlewatcherConfig) validateProvider() error {

View File

@@ -72,6 +72,9 @@ func NewMonitor(r types.Route) types.HealthMonCheck {
func newMonitor(u *url.URL, config *types.HealthCheckConfig, healthCheckFunc HealthCheckFunc) *monitor {
if config.Retries == 0 {
if config.Interval == 0 {
config.Interval = common.HealthCheckIntervalDefault
}
config.Retries = int64(common.HealthCheckDownNotifyDelayDefault / config.Interval)
}
mon := &monitor{
@@ -171,7 +174,9 @@ func (mon *monitor) Task() *task.Task {
// Finish implements task.TaskFinisher.
func (mon *monitor) Finish(reason any) {
mon.task.Finish(reason)
if mon.task != nil {
mon.task.Finish(reason)
}
}
// UpdateURL implements HealthChecker.