Skip to content

Commit a3831c9

Browse files
committed
Release n8n-gw v1.0
0 parents  commit a3831c9

39 files changed

Lines changed: 5157 additions & 0 deletions

.dockerignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
.git
2+
.github
3+
.gstack
4+
.omx
5+
.env
6+
.env.*
7+
!.env.example
8+
coverage.out
9+
tmp
10+
dist
11+
node_modules
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: Build and Push
2+
3+
on:
4+
push:
5+
branches: [main]
6+
tags: ['v*']
7+
pull_request:
8+
branches: [main]
9+
10+
env:
11+
IMAGE_NAME: ghcr.io/yangs1202/n8n-gw
12+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
13+
14+
permissions:
15+
contents: read
16+
packages: write
17+
18+
jobs:
19+
build:
20+
runs-on: ubuntu-latest
21+
steps:
22+
- name: Checkout
23+
uses: actions/checkout@v4
24+
25+
- name: Set up Docker Buildx
26+
uses: docker/setup-buildx-action@v3
27+
28+
- name: Login to GHCR
29+
if: github.event_name != 'pull_request'
30+
uses: docker/login-action@v3
31+
with:
32+
registry: ghcr.io
33+
username: ${{ github.actor }}
34+
password: ${{ secrets.GITHUB_TOKEN }}
35+
36+
- name: Set image tags
37+
id: tags
38+
shell: bash
39+
run: |
40+
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
41+
{
42+
echo "tags<<EOF"
43+
echo "${IMAGE_NAME}:${GITHUB_REF#refs/tags/}"
44+
echo "${IMAGE_NAME}:latest"
45+
echo "EOF"
46+
} >> "$GITHUB_OUTPUT"
47+
else
48+
{
49+
echo "tags<<EOF"
50+
echo "${IMAGE_NAME}:latest"
51+
echo "${IMAGE_NAME}:sha-${GITHUB_SHA::12}"
52+
echo "EOF"
53+
} >> "$GITHUB_OUTPUT"
54+
fi
55+
56+
- name: Build and push
57+
uses: docker/build-push-action@v5
58+
with:
59+
context: .
60+
push: ${{ github.event_name != 'pull_request' }}
61+
tags: ${{ steps.tags.outputs.tags }}
62+
cache-from: type=gha
63+
cache-to: type=gha,mode=max

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/n8n-proxy
2+
coverage.out
3+
.env
4+
.env.*
5+
!.env.example

Dockerfile

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
FROM golang:1.26-alpine AS build
2+
3+
WORKDIR /src
4+
COPY go.mod go.sum ./
5+
RUN go mod download
6+
COPY . .
7+
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/n8n-gw ./cmd/n8n-proxy
8+
9+
FROM gcr.io/distroless/static-debian12:nonroot
10+
11+
COPY --from=build /out/n8n-gw /n8n-gw
12+
USER nonroot:nonroot
13+
EXPOSE 8080
14+
ENTRYPOINT ["/n8n-gw"]

README.md

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# n8n-gw
2+
3+
OIDC gateway for n8n Community Edition.
4+
5+
`n8n-gw` is a reverse proxy that adds OIDC login to n8n Community Edition without patching n8n itself. Browser users authenticate with an OIDC provider, then the gateway maps that identity to an existing n8n account stored in Vault, performs the n8n UI login server-side, and forwards the resulting browser session to n8n.
6+
7+
Public execution endpoints such as webhooks and forms bypass OIDC so external services can continue calling n8n normally.
8+
9+
## Features
10+
11+
- OIDC authentication for n8n console access
12+
- Vault-backed mapping from OIDC users to n8n credentials
13+
- Server-side n8n `/rest/login` bridge
14+
- Redis-backed gateway sessions and OIDC state
15+
- Pass-through routing for `/webhook/*`, `/form/*`, and related public execution paths
16+
- External `POST /rest/login` blocking
17+
- Health and readiness endpoints
18+
- Prometheus metrics
19+
20+
## Container Image
21+
22+
```bash
23+
docker pull ghcr.io/yangs1202/n8n-gw:latest
24+
docker pull ghcr.io/yangs1202/n8n-gw:v1.0
25+
```
26+
27+
## Required Configuration
28+
29+
```bash
30+
PUBLIC_BASE_URL=https://proxy.example.com
31+
N8N_UPSTREAM_URL=http://n8n:5678
32+
OIDC_ISSUER_URL=https://idp.example.com
33+
OIDC_CLIENT_ID=n8n-gw
34+
OIDC_CLIENT_SECRET=change-me
35+
REDIS_URL=redis://redis:6379/0
36+
VAULT_ADDR=https://vault.example.com
37+
VAULT_TOKEN=change-me
38+
```
39+
40+
Production deployments should prefer Vault AppRole instead of a long-lived Vault token:
41+
42+
```bash
43+
VAULT_ROLE_ID=change-me
44+
VAULT_SECRET_ID=change-me
45+
```
46+
47+
Optional defaults:
48+
49+
```bash
50+
OIDC_SCOPES="openid profile email"
51+
VAULT_KV_MOUNT=secret
52+
VAULT_KV_PREFIX=n8n-gw/users
53+
PUBLIC_BYPASS_PREFIXES=/webhook/,/webhook-test/,/webhook-waiting/,/form/,/form-test/,/forms/,/forms-test/
54+
```
55+
56+
## Local Run
57+
58+
Create a local `.env` file from the variables above, then run:
59+
60+
```bash
61+
./scripts/run-local.sh
62+
```
63+
64+
## Security Notes
65+
66+
- Do not expose the upstream n8n service directly to the public internet.
67+
- Do not commit `.env`, Vault tokens, OIDC client secrets, AppRole IDs, AppRole secrets, Kubernetes manifests, private ingress details, or production hostnames to this repository.
68+
- Public execution routes such as `/webhook/*` and `/form/*` bypass OIDC by design. Configure authentication at the n8n workflow or upstream edge layer when needed.
69+
- External `POST /rest/login` requests are blocked by the gateway. Only the gateway's internal n8n client calls upstream n8n login.
70+
- n8n `/rest/login` is an internal UI endpoint, not a stable public API. Re-test this bridge when upgrading n8n.
71+
72+
## Documentation
73+
74+
Implementation details are in [docs/README.md](docs/README.md).
75+
76+
---
77+
78+
# n8n-gw 한국어
79+
80+
n8n Community Edition을 수정하지 않고 OIDC 로그인을 붙이는 게이트웨이입니다.
81+
82+
`n8n-gw`는 n8n 앞단에 위치하는 reverse proxy입니다. 브라우저 사용자는 OIDC로 인증하고, 게이트웨이는 OIDC 사용자를 Vault에 저장된 기존 n8n 계정 정보와 매핑합니다. 그 다음 게이트웨이가 서버 사이드에서 n8n UI 로그인을 수행하고, 발급된 n8n 브라우저 세션을 사용자에게 전달합니다.
83+
84+
웹훅과 폼 같은 public execution endpoint는 OIDC로 리다이렉트하지 않고 그대로 n8n으로 전달합니다.
85+
86+
## 주요 기능
87+
88+
- n8n 콘솔 접근에 OIDC 인증 추가
89+
- Vault 기반 OIDC 사용자와 n8n 계정 매핑
90+
- 서버 사이드 n8n `/rest/login` 브리지
91+
- Redis 기반 gateway session 및 OIDC state 저장
92+
- `/webhook/*`, `/form/*` 등 public execution path 바이패스
93+
- 외부 `POST /rest/login` 차단
94+
- health/readiness endpoint 제공
95+
- Prometheus metrics 제공
96+
97+
## 컨테이너 이미지
98+
99+
```bash
100+
docker pull ghcr.io/yangs1202/n8n-gw:latest
101+
docker pull ghcr.io/yangs1202/n8n-gw:v1.0
102+
```
103+
104+
## 필수 설정
105+
106+
```bash
107+
PUBLIC_BASE_URL=https://proxy.example.com
108+
N8N_UPSTREAM_URL=http://n8n:5678
109+
OIDC_ISSUER_URL=https://idp.example.com
110+
OIDC_CLIENT_ID=n8n-gw
111+
OIDC_CLIENT_SECRET=change-me
112+
REDIS_URL=redis://redis:6379/0
113+
VAULT_ADDR=https://vault.example.com
114+
VAULT_TOKEN=change-me
115+
```
116+
117+
운영 환경에서는 장기 Vault token보다 Vault AppRole 사용을 권장합니다.
118+
119+
```bash
120+
VAULT_ROLE_ID=change-me
121+
VAULT_SECRET_ID=change-me
122+
```
123+
124+
## 보안 주의사항
125+
126+
- upstream n8n service를 public internet에 직접 노출하지 마세요.
127+
- `.env`, Vault token, OIDC client secret, AppRole ID, AppRole secret, Kubernetes manifest, private ingress 정보, 운영 hostname을 repository에 commit하지 마세요.
128+
- `/webhook/*`, `/form/*` 같은 public execution route는 의도적으로 OIDC를 우회합니다. 필요한 인증은 n8n workflow 또는 edge layer에서 설정하세요.
129+
- 외부 `POST /rest/login`은 gateway가 차단합니다. upstream n8n login은 gateway 내부 client만 호출합니다.
130+
- n8n `/rest/login`은 안정적인 public API가 아니라 UI 내부 endpoint입니다. n8n 업그레이드 시 반드시 integration test로 다시 확인하세요.
131+
132+
---
133+
134+
# n8n-gw 中文
135+
136+
用于 n8n Community Edition 的 OIDC 网关。
137+
138+
`n8n-gw` 是部署在 n8n 前面的反向代理。用户通过 OIDC 登录后,网关会把 OIDC 身份映射到 Vault 中保存的现有 n8n 账号和密码,然后在服务端调用 n8n UI 登录接口,并把生成的浏览器会话转发给用户。
139+
140+
Webhook、form 等公开执行路径不会触发 OIDC 重定向,因此外部服务可以继续正常调用 n8n。
141+
142+
## 功能
143+
144+
- 为 n8n 控制台访问添加 OIDC 认证
145+
- 使用 Vault 保存 OIDC 用户到 n8n 账号的映射
146+
- 服务端 n8n `/rest/login` 登录桥接
147+
- 使用 Redis 保存网关会话和 OIDC state
148+
- 直通 `/webhook/*``/form/*` 等公开执行路径
149+
- 阻止外部 `POST /rest/login`
150+
- 提供 health/readiness 接口
151+
- 提供 Prometheus metrics
152+
153+
## 容器镜像
154+
155+
```bash
156+
docker pull ghcr.io/yangs1202/n8n-gw:latest
157+
docker pull ghcr.io/yangs1202/n8n-gw:v1.0
158+
```
159+
160+
## 必需配置
161+
162+
```bash
163+
PUBLIC_BASE_URL=https://proxy.example.com
164+
N8N_UPSTREAM_URL=http://n8n:5678
165+
OIDC_ISSUER_URL=https://idp.example.com
166+
OIDC_CLIENT_ID=n8n-gw
167+
OIDC_CLIENT_SECRET=change-me
168+
REDIS_URL=redis://redis:6379/0
169+
VAULT_ADDR=https://vault.example.com
170+
VAULT_TOKEN=change-me
171+
```
172+
173+
生产环境建议使用 Vault AppRole,而不是长期有效的 Vault token。
174+
175+
```bash
176+
VAULT_ROLE_ID=change-me
177+
VAULT_SECRET_ID=change-me
178+
```
179+
180+
## 安全注意事项
181+
182+
- 不要把上游 n8n 服务直接暴露到公网。
183+
- 不要提交 `.env`、Vault token、OIDC client secret、AppRole ID、AppRole secret、Kubernetes manifest、私有 ingress 信息或生产域名。
184+
- `/webhook/*``/form/*` 等公开执行路径会有意绕过 OIDC。需要认证时,请在 n8n workflow 或边缘网关层配置。
185+
- 外部 `POST /rest/login` 会被网关阻止。只有网关内部的 n8n client 会调用上游 n8n 登录接口。
186+
- n8n `/rest/login` 是 UI 内部接口,不是稳定的公开 API。升级 n8n 时必须重新运行集成测试。

cmd/n8n-proxy/main.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
"net/http"
7+
"os"
8+
"os/signal"
9+
"syscall"
10+
"time"
11+
12+
"github.com/yangs1202/n8n-gw/internal/config"
13+
"github.com/yangs1202/n8n-gw/internal/httpserver"
14+
"github.com/yangs1202/n8n-gw/internal/logging"
15+
"github.com/yangs1202/n8n-gw/internal/n8n"
16+
oidcclient "github.com/yangs1202/n8n-gw/internal/oidc"
17+
"github.com/yangs1202/n8n-gw/internal/session"
18+
"github.com/yangs1202/n8n-gw/internal/vault"
19+
)
20+
21+
func main() {
22+
cfg, err := config.Load()
23+
if err != nil {
24+
slog.Error("load config failed", "error", err)
25+
os.Exit(1)
26+
}
27+
28+
logger := logging.New(cfg.LogLevel)
29+
slog.SetDefault(logger)
30+
31+
ctx, cancel := context.WithCancel(context.Background())
32+
defer cancel()
33+
34+
sessionStore := session.NewRedisStore(cfg.RedisURL, cfg.SessionTTL, cfg.OIDCStateTTL)
35+
vaultStore, err := vault.NewStore(ctx, cfg.VaultAddr, vault.AuthConfig{
36+
Token: cfg.VaultToken,
37+
RoleID: cfg.VaultRoleID,
38+
SecretID: cfg.VaultSecretID,
39+
}, cfg.VaultKVMount, cfg.VaultKVPrefix)
40+
if err != nil {
41+
logger.Error("create vault store failed", "error", err)
42+
os.Exit(1)
43+
}
44+
45+
oidcProvider, err := oidcclient.New(ctx, oidcclient.Config{
46+
IssuerURL: cfg.OIDCIssuerURL,
47+
ClientID: cfg.OIDCClientID,
48+
ClientSecret: cfg.OIDCClientSecret,
49+
Scopes: cfg.OIDCScopes,
50+
RedirectURL: cfg.PublicBaseURL.JoinPath("/auth/callback").String(),
51+
})
52+
if err != nil {
53+
logger.Error("create oidc client failed", "error", err)
54+
os.Exit(1)
55+
}
56+
57+
n8nClient := n8n.NewClient(cfg.N8NUpstreamURL, cfg.ConsoleProxyTimeout)
58+
59+
app := httpserver.New(httpserver.Dependencies{
60+
Config: cfg,
61+
Logger: logger,
62+
Sessions: sessionStore,
63+
Credentials: vaultStore,
64+
OIDC: oidcProvider,
65+
N8N: n8nClient,
66+
PublicProxy: nil,
67+
ConsoleProxy: nil,
68+
})
69+
70+
server := &http.Server{
71+
Addr: cfg.ListenAddr,
72+
Handler: app,
73+
ReadHeaderTimeout: 10 * time.Second,
74+
}
75+
76+
errCh := make(chan error, 1)
77+
go func() {
78+
logger.Info("starting n8n proxy", "addr", cfg.ListenAddr, "public_base_url", cfg.PublicBaseURL.String(), "upstream", cfg.N8NUpstreamURL.String())
79+
errCh <- server.ListenAndServe()
80+
}()
81+
82+
sigCh := make(chan os.Signal, 1)
83+
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
84+
85+
select {
86+
case sig := <-sigCh:
87+
logger.Info("shutdown signal received", "signal", sig.String())
88+
case err := <-errCh:
89+
if err != nil && err != http.ErrServerClosed {
90+
logger.Error("http server failed", "error", err)
91+
os.Exit(1)
92+
}
93+
}
94+
95+
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
96+
defer shutdownCancel()
97+
if err := server.Shutdown(shutdownCtx); err != nil {
98+
logger.Error("http shutdown failed", "error", err)
99+
os.Exit(1)
100+
}
101+
}

0 commit comments

Comments
 (0)