mirror of
https://github.com/yusing/godoxy.git
synced 2026-02-16 15:37:42 +01:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aec937a114 | ||
|
|
bab9471bde | ||
|
|
4ebd1dbf32 | ||
|
|
82a4a61df0 | ||
|
|
9e56ea5db1 | ||
|
|
719682c99f | ||
|
|
f81a2b6607 | ||
|
|
f47ba0a9b5 | ||
|
|
52e949de85 | ||
|
|
abeb26b556 | ||
|
|
23d392d88b | ||
|
|
d588664bfa | ||
|
|
41ce784a7f |
@@ -47,6 +47,7 @@ FROM scratch
|
||||
|
||||
LABEL maintainer="yusing@6uo.me"
|
||||
LABEL proxy.exclude=1
|
||||
LABEL proxy.#1.healthcheck.disable=true
|
||||
|
||||
# copy timezone data
|
||||
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
|
||||
|
||||
8
Makefile
8
Makefile
@@ -113,9 +113,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
|
||||
|
||||
32
README.md
32
README.md
@@ -1,10 +1,11 @@
|
||||
<div align="center">
|
||||
|
||||
# GoDoxy
|
||||
<img src="assets/godoxy.png" width="200">
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||

|
||||
[](https://sonarcloud.io/summary/new_code?id=go-proxy)
|
||||
|
||||

|
||||
[](https://discord.gg/umReR62nRd)
|
||||
|
||||
@@ -16,10 +17,10 @@ A lightweight, simple, and performant reverse proxy with WebUI.
|
||||
|
||||
<h5>EN | <a href="README_CHT.md">中文</a></h5>
|
||||
|
||||
Have questions? Ask [ChatGPT](https://chatgpt.com/g/g-6825390374b481919ad482f2e48936a1-godoxy-assistant)! (Thanks to [@ismesid](https://github.com/arevindh))
|
||||
|
||||
<img src="screenshots/webui.jpg" style="max-width: 650">
|
||||
|
||||
Have questions? Ask [ChatGPT](https://chatgpt.com/g/g-6825390374b481919ad482f2e48936a1-godoxy-assistant)! (Thanks to [@ismesid](https://github.com/arevindh))
|
||||
|
||||
**New WebUI and is now available in nightly tag [(Demo)](https://nightly.demo.godoxy.dev), feedbacks are welcomed!**
|
||||
|
||||
</div>
|
||||
@@ -28,19 +29,18 @@ Have questions? Ask [ChatGPT](https://chatgpt.com/g/g-6825390374b481919ad482f2e4
|
||||
|
||||
<!-- TOC -->
|
||||
|
||||
- [GoDoxy](#godoxy)
|
||||
- [Table of content](#table-of-content)
|
||||
- [Running demo](#running-demo)
|
||||
- [Key Features](#key-features)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Setup](#setup)
|
||||
- [How does GoDoxy work](#how-does-godoxy-work)
|
||||
- [Screenshots](#screenshots)
|
||||
- [idlesleeper](#idlesleeper)
|
||||
- [Metrics and Logs](#metrics-and-logs)
|
||||
- [Manual Setup](#manual-setup)
|
||||
- [Folder structrue](#folder-structrue)
|
||||
- [Build it yourself](#build-it-yourself)
|
||||
- [Table of content](#table-of-content)
|
||||
- [Running demo](#running-demo)
|
||||
- [Key Features](#key-features)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Setup](#setup)
|
||||
- [How does GoDoxy work](#how-does-godoxy-work)
|
||||
- [Screenshots](#screenshots)
|
||||
- [idlesleeper](#idlesleeper)
|
||||
- [Metrics and Logs](#metrics-and-logs)
|
||||
- [Manual Setup](#manual-setup)
|
||||
- [Folder structrue](#folder-structrue)
|
||||
- [Build it yourself](#build-it-yourself)
|
||||
|
||||
## Running demo
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<div align="center">
|
||||
|
||||
# GoDoxy
|
||||
<img src="assets/godoxy.png" width="200">
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||

|
||||
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||
|
||||

|
||||
[](https://discord.gg/umReR62nRd)
|
||||
|
||||
@@ -16,28 +17,27 @@
|
||||
|
||||
<h5><a href="README.md">EN</a> | 中文</h5>
|
||||
|
||||
有疑問? 問 [ChatGPT](https://chatgpt.com/g/g-6825390374b481919ad482f2e48936a1-godoxy-assistant)!(鳴謝 [@ismesid](https://github.com/arevindh))
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/4bb371f4-6e4c-425c-89b2-b9e962bdd46f" style="max-width: 650">
|
||||
|
||||
有疑問? 問 [ChatGPT](https://chatgpt.com/g/g-6825390374b481919ad482f2e48936a1-godoxy-assistant)!(鳴謝 [@ismesid](https://github.com/arevindh))
|
||||
|
||||
</div>
|
||||
|
||||
## 目錄
|
||||
|
||||
<!-- TOC -->
|
||||
|
||||
- [GoDoxy](#godoxy)
|
||||
- [目錄](#目錄)
|
||||
- [運行示例](#運行示例)
|
||||
- [主要特點](#主要特點)
|
||||
- [前置需求](#前置需求)
|
||||
- [安裝](#安裝)
|
||||
- [手動安裝](#手動安裝)
|
||||
- [資料夾結構](#資料夾結構)
|
||||
- [截圖](#截圖)
|
||||
- [閒置休眠](#閒置休眠)
|
||||
- [監控](#監控)
|
||||
- [自行編譯](#自行編譯)
|
||||
- [目錄](#目錄)
|
||||
- [運行示例](#運行示例)
|
||||
- [主要特點](#主要特點)
|
||||
- [前置需求](#前置需求)
|
||||
- [安裝](#安裝)
|
||||
- [手動安裝](#手動安裝)
|
||||
- [資料夾結構](#資料夾結構)
|
||||
- [截圖](#截圖)
|
||||
- [閒置休眠](#閒置休眠)
|
||||
- [監控](#監控)
|
||||
- [自行編譯](#自行編譯)
|
||||
|
||||
## 運行示例
|
||||
|
||||
|
||||
BIN
assets/godoxy.png
Normal file
BIN
assets/godoxy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
33
dev.Dockerfile
Normal file
33
dev.Dockerfile
Normal 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
44
dev.compose.yml
Normal 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"
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -215,10 +216,23 @@ func (cfg *Config) StartServers(opts ...*StartServersOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
var envRegex = regexp.MustCompile(`\$\{([^}]+)\}`) // e.g. ${CLOUDFLARE_API_KEY}
|
||||
var readFile = os.ReadFile
|
||||
|
||||
func (cfg *Config) readConfigFile() ([]byte, error) {
|
||||
data, err := readFile(common.ConfigPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return envRegex.ReplaceAllFunc(data, func(match []byte) []byte {
|
||||
return strconv.AppendQuote(nil, os.Getenv(string(match[2:len(match)-1])))
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (cfg *Config) load() gperr.Error {
|
||||
const errMsg = "config load error"
|
||||
|
||||
data, err := os.ReadFile(common.ConfigPath)
|
||||
data, err := cfg.readConfigFile()
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Warn().Msg("config file not found, using default config")
|
||||
|
||||
38
internal/config/config_test.go
Normal file
38
internal/config/config_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConfigEnvSubstitution(t *testing.T) {
|
||||
os.Setenv("CLOUDFLARE_AUTH_TOKEN", "test")
|
||||
readFile = func(_ string) ([]byte, error) {
|
||||
return []byte(`
|
||||
---
|
||||
autocert:
|
||||
email: "test@test.com"
|
||||
domains:
|
||||
- "*.test.com"
|
||||
provider: cloudflare
|
||||
options:
|
||||
auth_token: ${CLOUDFLARE_AUTH_TOKEN}
|
||||
`), nil
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
out, err := cfg.readConfigFile()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, `
|
||||
---
|
||||
autocert:
|
||||
email: "test@test.com"
|
||||
domains:
|
||||
- "*.test.com"
|
||||
provider: cloudflare
|
||||
options:
|
||||
auth_token: "test"
|
||||
`, string(out))
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"maps"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -21,6 +22,8 @@ import (
|
||||
|
||||
var DummyContainer = new(types.Container)
|
||||
|
||||
var EnvDockerHost = os.Getenv("DOCKER_HOST")
|
||||
|
||||
var (
|
||||
ErrNetworkNotFound = errors.New("network not found")
|
||||
ErrNoNetwork = errors.New("no network found")
|
||||
@@ -160,6 +163,10 @@ func isLocal(c *types.Container) bool {
|
||||
if strings.HasPrefix(c.DockerHost, "unix://") {
|
||||
return true
|
||||
}
|
||||
// treat it as local if the docker host is the same as the environment variable
|
||||
if c.DockerHost == EnvDockerHost {
|
||||
return true
|
||||
}
|
||||
url, err := url.Parse(c.DockerHost)
|
||||
if err != nil {
|
||||
return false
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
35
internal/net/gphttp/middleware/oidc_test.go
Normal file
35
internal/net/gphttp/middleware/oidc_test.go
Normal 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, "")
|
||||
})
|
||||
}
|
||||
@@ -31,6 +31,7 @@ type Manager struct {
|
||||
err error
|
||||
|
||||
writeLock sync.Mutex
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
var defaultUpgrader = websocket.Upgrader{
|
||||
@@ -111,6 +112,12 @@ func NewManagerWithUpgrade(c *gin.Context) (*Manager, error) {
|
||||
go cm.pingCheckRoutine()
|
||||
go cm.readRoutine()
|
||||
|
||||
// Ensure resources are released when parent context is canceled.
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
cm.Close()
|
||||
}()
|
||||
|
||||
return cm, nil
|
||||
}
|
||||
|
||||
@@ -209,6 +216,10 @@ func (cm *Manager) ReadJSON(out any, timeout time.Duration) error {
|
||||
|
||||
// Close closes the connection and cancels the context
|
||||
func (cm *Manager) Close() {
|
||||
cm.closeOnce.Do(cm.close)
|
||||
}
|
||||
|
||||
func (cm *Manager) close() {
|
||||
cm.cancel()
|
||||
|
||||
cm.writeLock.Lock()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
75
rootless-compose.example.yml
Normal file
75
rootless-compose.example.yml
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
services:
|
||||
socket-proxy:
|
||||
container_name: socket-proxy
|
||||
image: ghcr.io/yusing/socket-proxy:latest
|
||||
environment:
|
||||
- ALLOW_START=1
|
||||
- ALLOW_STOP=1
|
||||
- ALLOW_RESTARTS=1
|
||||
- CONTAINERS=1
|
||||
- EVENTS=1
|
||||
- INFO=1
|
||||
- PING=1
|
||||
- POST=1
|
||||
- VERSION=1
|
||||
volumes:
|
||||
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
|
||||
restart: unless-stopped
|
||||
tmpfs:
|
||||
- /run
|
||||
networks:
|
||||
- godoxy
|
||||
frontend:
|
||||
image: ghcr.io/yusing/godoxy-frontend:${TAG:-latest}
|
||||
container_name: godoxy-frontend
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
read_only: true
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- all
|
||||
depends_on:
|
||||
- app
|
||||
environment:
|
||||
HOSTNAME: 0.0.0.0
|
||||
PORT: 3000
|
||||
labels:
|
||||
proxy.aliases: ${GODOXY_FRONTEND_ALIASES:-godoxy}
|
||||
proxy.#1.port: 3000
|
||||
networks:
|
||||
- godoxy
|
||||
app:
|
||||
image: yusing/godoxy:test
|
||||
container_name: godoxy-proxy
|
||||
restart: always
|
||||
env_file: .env
|
||||
depends_on:
|
||||
socket-proxy:
|
||||
condition: service_started
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- all
|
||||
cap_add:
|
||||
- NET_BIND_SERVICE
|
||||
environment:
|
||||
- DOCKER_HOST=tcp://${SOCKET_PROXY_LISTEN_ADDR:-127.0.0.1:2375}
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443/tcp
|
||||
- 443:443/udp # http3
|
||||
volumes:
|
||||
- ./config:/app/config
|
||||
- ./logs:/app/logs
|
||||
- ./error_pages:/app/error_pages:ro
|
||||
- ./data:/app/data
|
||||
- ./certs:/app/certs
|
||||
networks:
|
||||
- proxy
|
||||
- godoxy
|
||||
networks:
|
||||
proxy: # bridge network for all services that needs proxying
|
||||
external: true
|
||||
godoxy:
|
||||
72
rootless.env.example
Normal file
72
rootless.env.example
Normal file
@@ -0,0 +1,72 @@
|
||||
DOCKER_SOCKET=/var/run/user/1000/docker.sock
|
||||
SOCKET_PROXY_LISTEN_ADDR=socket-proxy:2375
|
||||
|
||||
# docker image tag (latest, nightly)
|
||||
TAG=latest
|
||||
|
||||
# set timezone to get correct log timestamp
|
||||
TZ=ETC/UTC
|
||||
|
||||
# Set GODOXY_API_JWT_SECURE=false to allow http
|
||||
GODOXY_API_JWT_SECURE=true
|
||||
# API JWT Configuration (common)
|
||||
# generate secret with `openssl rand -base64 32`
|
||||
GODOXY_API_JWT_SECRET=
|
||||
# the JWT token time-to-live
|
||||
# leave empty to use default (24 hours)
|
||||
# format: https://pkg.go.dev/time#Duration
|
||||
GODOXY_API_JWT_TOKEN_TTL=
|
||||
|
||||
# API/WebUI user password login credentials (optional)
|
||||
# These fields are not required for OIDC authentication
|
||||
GODOXY_API_USER=admin
|
||||
GODOXY_API_PASSWORD=password
|
||||
|
||||
# OIDC Configuration (optional)
|
||||
# Uncomment and configure these values to enable OIDC authentication.
|
||||
#
|
||||
# GODOXY_OIDC_ISSUER_URL=https://accounts.google.com
|
||||
# GODOXY_OIDC_CLIENT_ID=your-client-id
|
||||
# GODOXY_OIDC_CLIENT_SECRET=your-client-secret
|
||||
# GODOXY_OIDC_SCOPES=openid, profile, email, groups # you may also include `offline_access` if your Idp supports it (e.g. Authentik, Pocket ID)
|
||||
#
|
||||
# User definitions: Uncomment and configure these values to restrict access to specific users or groups.
|
||||
# These two fields act as a logical AND operator. For example, given the following membership:
|
||||
# user1, group1
|
||||
# user2, group1
|
||||
# user3, group2
|
||||
# user1, group2
|
||||
# You can allow access to user3 AND all users of group1 by providing:
|
||||
# # GODOXY_OIDC_ALLOWED_USERS=user3
|
||||
# # GODOXY_OIDC_ALLOWED_GROUPS=group1
|
||||
#
|
||||
# Comma-separated list of allowed users.
|
||||
# GODOXY_OIDC_ALLOWED_USERS=user1,user2
|
||||
# Optional: Comma-separated list of allowed groups.
|
||||
# GODOXY_OIDC_ALLOWED_GROUPS=group1,group2
|
||||
|
||||
# Proxy listening address
|
||||
GODOXY_HTTP_ADDR=:80
|
||||
GODOXY_HTTPS_ADDR=:443
|
||||
|
||||
# Enable HTTP3
|
||||
GODOXY_HTTP3_ENABLED=true
|
||||
|
||||
# API listening address
|
||||
GODOXY_API_ADDR=127.0.0.1:8888
|
||||
|
||||
# Metrics
|
||||
GODOXY_METRICS_DISABLE_CPU=false
|
||||
GODOXY_METRICS_DISABLE_MEMORY=false
|
||||
GODOXY_METRICS_DISABLE_DISK=false
|
||||
GODOXY_METRICS_DISABLE_NETWORK=false
|
||||
GODOXY_METRICS_DISABLE_SENSORS=false
|
||||
|
||||
# Frontend listening port
|
||||
GODOXY_FRONTEND_PORT=3000
|
||||
|
||||
# Frontend aliases (subdomains / FQDNs, e.g. godoxy, godoxy.domain.com)
|
||||
GODOXY_FRONTEND_ALIASES=godoxy
|
||||
|
||||
# Debug mode
|
||||
GODOXY_DEBUG=false
|
||||
@@ -180,7 +180,24 @@ for dir in "${REQUIRED_DIRECTORIES[@]}"; do
|
||||
mkdir_if_not_exists "$dir"
|
||||
done
|
||||
|
||||
# 2. .env file
|
||||
# 2. check if rootless docker is used, verify again with user input
|
||||
if docker info -f "{{println .SecurityOptions}}" | grep rootless >/dev/null 2>&1; then
|
||||
ask_while_empty "Rootless docker detected, is this correct? (y/n): " USE_ROOTLESS_DOCKER
|
||||
if [ "$USE_ROOTLESS_DOCKER" == "n" ]; then
|
||||
USE_ROOTLESS_DOCKER="false"
|
||||
else
|
||||
USE_ROOTLESS_DOCKER="true"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3. if rootless docker is used, switch to rootless docker compose and .env
|
||||
if [ "$USE_ROOTLESS_DOCKER" == "true" ]; then
|
||||
COMPOSE_EXAMPLE_FILE_NAME="rootless-compose.example.yml"
|
||||
DOT_ENV_EXAMPLE_PATH="rootless.env.example"
|
||||
fi
|
||||
|
||||
|
||||
# 4. .env file
|
||||
fetch_file "$DOT_ENV_EXAMPLE_PATH" "$DOT_ENV_PATH"
|
||||
|
||||
# set random JWT secret
|
||||
@@ -192,13 +209,13 @@ if [ -n "$TIMEZONE" ]; then
|
||||
setenv "TZ" "$TIMEZONE"
|
||||
fi
|
||||
|
||||
# 3. docker-compose.yml
|
||||
# 5. docker-compose.yml
|
||||
fetch_file "$COMPOSE_EXAMPLE_FILE_NAME" "$COMPOSE_FILE_NAME"
|
||||
|
||||
# 4. config.yml
|
||||
# 6. config.yml
|
||||
fetch_file "$CONFIG_EXAMPLE_FILE_NAME" "$CONFIG_FILE_PATH"
|
||||
|
||||
# 5. setup authentication
|
||||
# 7. setup authentication
|
||||
|
||||
# ask for user and password
|
||||
echo "Setting up login user"
|
||||
@@ -208,7 +225,7 @@ echo "Setting up login user \"$LOGIN_USERNAME\" with password \"$LOGIN_PASSWORD\
|
||||
setenv "GODOXY_API_USER" "$LOGIN_USERNAME"
|
||||
setenv "GODOXY_API_PASSWORD" "$LOGIN_PASSWORD"
|
||||
|
||||
# 6. setup autocert
|
||||
# 8. setup autocert
|
||||
ask_while_empty "Configure autocert? (y/n): " ENABLE_AUTOCERT
|
||||
|
||||
# quit if not using autocert
|
||||
@@ -269,8 +286,34 @@ autocert:
|
||||
fi
|
||||
fi
|
||||
|
||||
# 7. set uid and gid
|
||||
setenv "GODOXY_UID" "$(id -u)"
|
||||
setenv "GODOXY_GID" "$(id -g)"
|
||||
# 9. set uid and gid
|
||||
if [ "$USE_ROOTLESS_DOCKER" == "false" ]; then
|
||||
setenv "GODOXY_UID" "$(id -u)"
|
||||
setenv "GODOXY_GID" "$(id -g)"
|
||||
else
|
||||
setenv "DOCKER_SOCKET" "/var/run/user/$(id -u)/docker.sock"
|
||||
fi
|
||||
|
||||
# 10. proxy network (rootless docker only)
|
||||
if [ "$USE_ROOTLESS_DOCKER" == "true" ]; then
|
||||
echo "Setting up proxy network"
|
||||
echo "Available networks:"
|
||||
docker network ls
|
||||
echo
|
||||
ask_while_empty "Which network to use for proxy? (default: proxy): " PROXY_NETWORK
|
||||
# check if network exists
|
||||
if ! docker network ls | grep -q "$PROXY_NETWORK"; then
|
||||
ask_while_empty "Network \"$PROXY_NETWORK\" does not exist, do you want to create it? (y/n): " CREATE_NETWORK
|
||||
if [ "$CREATE_NETWORK" == "y" ]; then
|
||||
docker network create "$PROXY_NETWORK"
|
||||
echo "Network \"$PROXY_NETWORK\" created"
|
||||
else
|
||||
echo "Error: network \"$PROXY_NETWORK\" does not exist, please create it first"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
sed -i "s|proxy: #|\"$PROXY_NETWORK\": #|" "$COMPOSE_FILE_NAME"
|
||||
sed -i "s|- proxy|- \"$PROXY_NETWORK\"|" "$COMPOSE_FILE_NAME"
|
||||
fi
|
||||
|
||||
echo "Setup finished"
|
||||
|
||||
Reference in New Issue
Block a user