mirror of
https://github.com/tinyauthapp/tinyauth.git
synced 2026-09-22 18:43:34 +08:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a62ef392e2 | ||
|
|
261d882af3 | ||
|
|
996316b524 | ||
|
|
90e898abbf | ||
|
|
4341445290 | ||
|
|
856c9b3d5b | ||
|
|
a10b24dfe0 | ||
|
|
34456b94b1 | ||
|
|
45e165f742 | ||
|
|
b43cf76d9b | ||
|
|
5a685c4a6b | ||
|
|
653b747829 | ||
|
|
ba79a30f2e | ||
|
|
8c5094bbcd | ||
|
|
c28d9fe55e | ||
|
|
f58a6dccf5 | ||
|
|
0cf64e8598 | ||
|
|
b61fc6b54e | ||
|
|
bbcc8cafa6 | ||
|
|
b63a6917fe | ||
|
|
59116f7344 | ||
|
|
97eb1efb0e | ||
|
|
1112d84667 | ||
|
|
b01f49cc44 | ||
|
|
e1b1e722e7 | ||
|
|
3a216e95e2 | ||
|
|
2769725775 | ||
|
|
419da20681 |
+1
-1
@@ -204,7 +204,7 @@ TINYAUTH_UI_TITLE="Tinyauth"
|
|||||||
# Message displayed on the forgot password page.
|
# Message displayed on the forgot password page.
|
||||||
TINYAUTH_UI_FORGOTPASSWORDMESSAGE="You can change your password by changing the configuration."
|
TINYAUTH_UI_FORGOTPASSWORDMESSAGE="You can change your password by changing the configuration."
|
||||||
# Path to the background image.
|
# Path to the background image.
|
||||||
TINYAUTH_UI_BACKGROUNDIMAGE="/background.jpg"
|
TINYAUTH_UI_BACKGROUNDIMAGE="/background.webp"
|
||||||
# Enable UI warnings.
|
# Enable UI warnings.
|
||||||
TINYAUTH_UI_WARNINGSENABLED=true
|
TINYAUTH_UI_WARNINGSENABLED=true
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,6 @@ jobs:
|
|||||||
run: go test -coverprofile=coverage.txt -v ./...
|
run: go test -coverprofile=coverage.txt -v ./...
|
||||||
|
|
||||||
- name: Upload coverage reports to Codecov
|
- name: Upload coverage reports to Codecov
|
||||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
uses: codecov/codecov-action@0b35c9ecc4f0529d0eb674914510c22f85b196b4 # v7.1.0
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
REPO: ${{ github.event.repository.name }}
|
REPO: ${{ github.event.repository.name }}
|
||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
|
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: nightly
|
tag_name: nightly
|
||||||
@@ -174,7 +174,7 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||||
@@ -233,7 +233,7 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||||
@@ -292,7 +292,7 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||||
@@ -351,7 +351,7 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||||
@@ -406,7 +406,7 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
@@ -445,7 +445,7 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
@@ -476,7 +476,7 @@ jobs:
|
|||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
|
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||||
with:
|
with:
|
||||||
files: binaries/*
|
files: binaries/*
|
||||||
tag_name: nightly
|
tag_name: nightly
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||||
@@ -203,7 +203,7 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||||
@@ -260,7 +260,7 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||||
@@ -317,7 +317,7 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||||
@@ -373,7 +373,7 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
@@ -414,7 +414,7 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
@@ -449,6 +449,6 @@ jobs:
|
|||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
|
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||||
with:
|
with:
|
||||||
files: binaries/*
|
files: binaries/*
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# Site builder
|
# Site builder
|
||||||
FROM node:26.7-alpine3.23 AS frontend-builder
|
FROM node:26.8-alpine3.23 AS frontend-builder
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Site builder
|
# Site builder
|
||||||
FROM node:26.7-alpine3.23 AS frontend-builder
|
FROM node:26.8-alpine3.23 AS frontend-builder
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
|
|
||||||
|
|||||||
@@ -81,10 +81,9 @@ A huge thank you to [selfh.st](https://selfh.st) for their generous donation to
|
|||||||
|
|
||||||
- [JetBrains for Open-Source](https://jb.gg/OpenSource)
|
- [JetBrains for Open-Source](https://jb.gg/OpenSource)
|
||||||
- [CodeRabbit AI](https://www.coderabbit.ai)
|
- [CodeRabbit AI](https://www.coderabbit.ai)
|
||||||
- [InstaPods](https://instapods.com) - [Deploy Tinyauth from 3$/month](https://app.instapods.com/dashboard/pods/create?app=tinyauth&ref=tinyauth)
|
|
||||||
|
|
||||||
## Acknowledgements
|
## Acknowledgements
|
||||||
|
|
||||||
- **Freepik** for providing the police hat and badge.
|
- **Freepik** for providing the police hat and badge.
|
||||||
- **Renee French** for the original gopher logo.
|
- **Renee French** for the original gopher logo.
|
||||||
- **Syrhu** for providing the background image of the app.
|
- [Siru Zhou](https://unsplash.com/@syrhu) for providing the background image of the app.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -4,9 +4,13 @@ export default defineConfig({
|
|||||||
testDir: './specs',
|
testDir: './specs',
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: false,
|
forbidOnly: false,
|
||||||
retries: 0,
|
retries: 3,
|
||||||
workers: 4,
|
workers: 4,
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
|
timeout: 60_000,
|
||||||
|
expect: {
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
use: {
|
use: {
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
video: 'on',
|
video: 'on',
|
||||||
|
|||||||
+11
-11
@@ -23,38 +23,38 @@
|
|||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"i18next": "^26.4.0",
|
"i18next": "^26.4.2",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"i18next-resources-to-backend": "^1.2.3",
|
"i18next-resources-to-backend": "^1.2.3",
|
||||||
"lucide-react": "^1.35.0",
|
"lucide-react": "^1.40.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.6.7",
|
"radix-ui": "^1.6.7",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.86.0",
|
"react-hook-form": "^7.87.0",
|
||||||
"react-i18next": "^17.0.12",
|
"react-i18next": "^17.0.13",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router": "^8.3.0",
|
"react-router": "^8.3.1",
|
||||||
"sonner": "^2.0.8",
|
"sonner": "^2.0.8",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tanstack/eslint-plugin-query": "^5.102.8",
|
"@tanstack/eslint-plugin-query": "^5.102.8",
|
||||||
"@types/node": "^26.4.0",
|
"@types/node": "^26.4.1",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.5",
|
"@types/react-dom": "^19.2.7",
|
||||||
"@vitejs/plugin-react": "^6.1.1",
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
"eslint": "^10.9.1",
|
"eslint": "^10.9.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.5",
|
"eslint-plugin-react-refresh": "^0.5.6",
|
||||||
"globals": "^17.11.0",
|
"globals": "^17.12.0",
|
||||||
"rollup-plugin-visualizer": "^7.1.1",
|
"rollup-plugin-visualizer": "^7.1.1",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.68.0",
|
"typescript-eslint": "^8.69.0",
|
||||||
"vite": "^8.2.2"
|
"vite": "^8.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+431
-343
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 530 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 224 KiB |
@@ -1,104 +1,104 @@
|
|||||||
{
|
{
|
||||||
"loginTitle": "Welcome back, login with",
|
"loginTitle": "Benvingut de nou, inicieu la sessió amb",
|
||||||
"loginTitleSimple": "Welcome back, please login",
|
"loginTitleSimple": "Benvingut de nou, si us plau, inicieu sessió",
|
||||||
"loginDivider": "Or",
|
"loginDivider": "O",
|
||||||
"loginUsername": "Username",
|
"loginUsername": "Nom d’usuari",
|
||||||
"loginPassword": "Password",
|
"loginPassword": "Contrasenya",
|
||||||
"loginSubmit": "Login",
|
"loginSubmit": "Iniciar sessió",
|
||||||
"loginFailTitle": "Failed to log in",
|
"loginFailTitle": "No s'ha pogut iniciar sessió",
|
||||||
"loginFailSubtitle": "Please check your username and password",
|
"loginFailSubtitle": "Si us plau, comproveu el vostre nom d'usuari i contrasenya",
|
||||||
"loginFailRateLimit": "You failed to login too many times. Please try again later",
|
"loginFailRateLimit": "Heu fallat en iniciar sessió massa vegades. Si us plau, proveu-ho més tard",
|
||||||
"loginSuccessTitle": "Logged in",
|
"loginSuccessTitle": "Heu iniciat sessió",
|
||||||
"loginSuccessSubtitle": "Welcome back!",
|
"loginSuccessSubtitle": "Benvingut de nou!",
|
||||||
"loginOauthFailTitle": "An error occurred",
|
"loginOauthFailTitle": "S'ha produït un error",
|
||||||
"loginOauthFailSubtitle": "Failed to get OAuth URL",
|
"loginOauthFailSubtitle": "Errada en obtenir l'URL OAuth",
|
||||||
"loginOauthSuccessTitle": "Redirecting",
|
"loginOauthSuccessTitle": "Redirigint",
|
||||||
"loginOauthSuccessSubtitle": "Redirecting to your OAuth provider",
|
"loginOauthSuccessSubtitle": "Redirigint al vostre proveïdor OAuth",
|
||||||
"loginOauthAutoRedirectTitle": "OAuth Auto Redirect",
|
"loginOauthAutoRedirectTitle": "Autoredirecció OAuth",
|
||||||
"loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.",
|
"loginOauthAutoRedirectSubtitle": "Sereu automàticament redirigits al vostre proveïdor OAuth per a autenticar-vos.",
|
||||||
"loginOauthAutoRedirectButton": "Redirect now",
|
"loginOauthAutoRedirectButton": "Redirigeix ara",
|
||||||
"continueTitle": "Continue",
|
"continueTitle": "Continuar",
|
||||||
"continueRedirectingTitle": "Redirecting...",
|
"continueRedirectingTitle": "S'està redirigint...",
|
||||||
"continueRedirectingSubtitle": "You should be redirected to the app soon",
|
"continueRedirectingSubtitle": "Hauríeu de ser redirigits aviat a l'app",
|
||||||
"continueRedirectManually": "Redirect me manually",
|
"continueRedirectManually": "Redirigiu-me manualment",
|
||||||
"continueInsecureRedirectTitle": "Insecure redirect",
|
"continueInsecureRedirectTitle": "Redirecció no segura",
|
||||||
"continueInsecureRedirectSubtitle": "You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?",
|
"continueInsecureRedirectSubtitle": "Esteu intentant redirigir des de <code>https</code>a<code>http</code> que no és segur. Esteu segurs de voler continuar?",
|
||||||
"continueUntrustedRedirectTitle": "Untrusted redirect",
|
"continueUntrustedRedirectTitle": "Redirecció no confiable",
|
||||||
"continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?",
|
"continueUntrustedRedirectSubtitle": "Esteu intentant redirigir a un domini que no coincideix amb el vostre domini (<code>{{cookieDomain}}</code>). Esteu segurs de voler continuar?",
|
||||||
"logoutFailTitle": "Failed to log out",
|
"logoutFailTitle": "Errada en tancar la sessió",
|
||||||
"logoutFailSubtitle": "Please try again",
|
"logoutFailSubtitle": "Si us plau, torneu-ho a provar",
|
||||||
"logoutSuccessTitle": "Logged out",
|
"logoutSuccessTitle": "Sessió tancada",
|
||||||
"logoutSuccessSubtitle": "You have been logged out",
|
"logoutSuccessSubtitle": "S'ha tancat la sessió",
|
||||||
"logoutTitle": "Logout",
|
"logoutTitle": "Tanca la sessió",
|
||||||
"logoutUsernameSubtitle": "You are currently logged in as <code>{{username}}</code>. Click the button below to logout.",
|
"logoutUsernameSubtitle": "Heu iniciat sessió com a <code>{{username}}</code>. Cliqueu el botó de sota per a tancar la sessió.",
|
||||||
"logoutOauthSubtitle": "You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.",
|
"logoutOauthSubtitle": "Heu iniciat la sessió actualment com a <code>{{username}}</code> fent servir {{provider}} com a proveïdor OAuth. Cliqueu el botó de sota per a tancar la sessió.",
|
||||||
"notFoundTitle": "Page not found",
|
"notFoundTitle": "Pàgina no trobada",
|
||||||
"notFoundSubtitle": "The page you are looking for does not exist.",
|
"notFoundSubtitle": "La pàgina que cerques no existeix.",
|
||||||
"notFoundButton": "Go home",
|
"notFoundButton": "Torna a l'inici",
|
||||||
"totpFailTitle": "Failed to verify code",
|
"totpFailTitle": "Errada en verificar el codi",
|
||||||
"totpFailSubtitle": "Please check your code and try again",
|
"totpFailSubtitle": "Si us plau, comprova el codi i torna-ho a provar",
|
||||||
"totpSuccessTitle": "Verified",
|
"totpSuccessTitle": "Verificat",
|
||||||
"totpSuccessSubtitle": "Redirecting to your app",
|
"totpSuccessSubtitle": "Redirigint a la teva app",
|
||||||
"totpTitle": "Enter your TOTP code",
|
"totpTitle": "Introdueixi el codi TOTP",
|
||||||
"totpSubtitle": "Please enter the code from your authenticator app.",
|
"totpSubtitle": "Si us plau, introdueix el codi de la teva aplicació d'autenticació.",
|
||||||
"unauthorizedTitle": "Unauthorized",
|
"unauthorizedTitle": "No autoritzat",
|
||||||
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
|
"unauthorizedResourceSubtitle": "L'usuari amb el nom <code>{{username}}</code> no està autoritzat a accedir al recurs <code>{{resource}}</code>.",
|
||||||
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
|
"unauthorizedLoginSubtitle": "L'usuari amb el nom <code>{{username}}</code> no està autoritzat a iniciar la sessió.",
|
||||||
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
|
"unauthorizedGroupsSubtitle": "L'usuari amb el nom <code>{{username}}</code> no està als grups requerits per al recurs <code>{{resource}}</code>.",
|
||||||
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
|
"unauthorizedIpSubtitle": "La vostra adreça IP <code>{{ip}}</code> no està autoritzada a accedir al recurs <code>{{resource}}</code>.",
|
||||||
"unauthorizedButton": "Try again",
|
"unauthorizedButton": "Tornar-ho a provar",
|
||||||
"cancelTitle": "Cancel",
|
"cancelTitle": "Cancel·lar",
|
||||||
"forgotPasswordTitle": "Forgot your password?",
|
"forgotPasswordTitle": "Heu oblidat la vostra contrasenya?",
|
||||||
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
|
"failedToFetchProvidersTitle": "Errada en carregar els proveïdors d'autenticació. Si us plau, verifiqueu la vostra configuració.",
|
||||||
"errorTitle": "An error occurred",
|
"errorTitle": "S'ha produït un error",
|
||||||
"errorSubtitleInfo": "The following error occurred while processing your request:",
|
"errorSubtitleInfo": "Ha ocorregut l'error següent mentre es processava la vostra petició:",
|
||||||
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
|
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
|
||||||
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.",
|
"forgotPasswordMessage": "Podeu reiniciar la vostra contrasenya canviant la variable d'entorn `USERS`.",
|
||||||
"fieldRequired": "This field is required",
|
"fieldRequired": "Aquest camp és obligatori",
|
||||||
"invalidInput": "Invalid input",
|
"invalidInput": "Entrada no vàlida",
|
||||||
"domainWarningTitle": "Invalid Domain",
|
"domainWarningTitle": "Domini invàlid",
|
||||||
"domainWarningSubtitle": "You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.",
|
"domainWarningSubtitle": "Esteu visitant aquesta instància des d'un domini incorrecte. Si procediu, podríeu trobar-os amb problemes d'autenticació.",
|
||||||
"domainWarningCurrent": "Current:",
|
"domainWarningCurrent": "Actual:",
|
||||||
"domainWarningExpected": "Expected:",
|
"domainWarningExpected": "Esperat:",
|
||||||
"ignoreTitle": "Ignore",
|
"ignoreTitle": "Ignorar",
|
||||||
"goToCorrectDomainTitle": "Go to correct domain",
|
"goToCorrectDomainTitle": "Anar al domini correcte",
|
||||||
"authorizeTitle": "Authorize",
|
"authorizeTitle": "Autoritzar",
|
||||||
"authorizeCardTitle": "Continue to {{app}}?",
|
"authorizeCardTitle": "Continuar a {{app}}?",
|
||||||
"authorizeSubtitle": "Would you like to continue to this app? Please carefully review the permissions requested by the app.",
|
"authorizeSubtitle": "Voldríeu continuar a aquesta app? Si us plau, reviseu amb cura els permisos sol·licitats per a l'app.",
|
||||||
"authorizeSubtitleOAuth": "Would you like to continue to this app?",
|
"authorizeSubtitleOAuth": "Voldríeu continuar a aquesta app?",
|
||||||
"authorizeLoadingTitle": "Loading...",
|
"authorizeLoadingTitle": "S'està carregant...",
|
||||||
"authorizeLoadingSubtitle": "Please wait while we load the client information.",
|
"authorizeLoadingSubtitle": "Si us plau, espereu mentre carreguem la informació del client.",
|
||||||
"authorizeSuccessTitle": "Authorized",
|
"authorizeSuccessTitle": "Autoritzat",
|
||||||
"authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.",
|
"authorizeSuccessSubtitle": "Sereu redirigits a l'app en pocs segons.",
|
||||||
"authorizeErrorClientInfo": "An error occurred while loading the client information. Please try again later.",
|
"authorizeErrorClientInfo": "Ha ocorregut un error mentre es carregava la informació del client. Si us plau, proveu-ho més tard.",
|
||||||
"authorizeErrorInvalidParams": "The request is missing required parameters or has invalid parameters. Please check the URL and try again.",
|
"authorizeErrorInvalidParams": "La petició manca dels paràmetres requerits o té paràmetres invàlids. Si us plau, comproveu l'URL i torneu-ho a provar.",
|
||||||
"openidScopeName": "OpenID Connect",
|
"openidScopeName": "OpenID Connect",
|
||||||
"openidScopeDescription": "Allows the app to access your OpenID Connect information.",
|
"openidScopeDescription": "Permet a l'app l'accés a la vostra informació OpenID Connect.",
|
||||||
"emailScopeName": "Email",
|
"emailScopeName": "Correu electrònic",
|
||||||
"emailScopeDescription": "Allows the app to access your email address.",
|
"emailScopeDescription": "Permet a l'app l'accés a la vostra adreça de correu electrònic.",
|
||||||
"profileScopeName": "Profile",
|
"profileScopeName": "Perfil",
|
||||||
"profileScopeDescription": "Allows the app to access your profile information.",
|
"profileScopeDescription": "Permet a l'app l'accés a la vostra informació de perfil.",
|
||||||
"groupsScopeName": "Groups",
|
"groupsScopeName": "Grups",
|
||||||
"groupsScopeDescription": "Allows the app to access your group information.",
|
"groupsScopeDescription": "Permet a l'app l'accés la vostra informació de grup.",
|
||||||
"backToLoginButton": "Back to login",
|
"backToLoginButton": "Tornar a l'inici de sessió",
|
||||||
"phoneScopeName": "Phone",
|
"phoneScopeName": "Telèfon",
|
||||||
"phoneScopeDescription": "Allows the app to access your phone number.",
|
"phoneScopeDescription": "Permet a l'app l'accés al vostre número de telèfon.",
|
||||||
"addressScopeName": "Address",
|
"addressScopeName": "Adreça",
|
||||||
"addressScopeDescription": "Allows the app to access your address.",
|
"addressScopeDescription": "Permet a l'app l'accés a la vostra adreça.",
|
||||||
"loginTailscaleTitle": "Continue with Tailscale",
|
"loginTailscaleTitle": "Continuar amb Tailscale",
|
||||||
"loginTailscaleDescription": "You appear to be accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?",
|
"loginTailscaleDescription": "Sembla que esteu accedint a Tinyauth des d'un dispositiu Tailscale autoritzat. Voldríeu continuar amb la vostra connexió Tailscale?",
|
||||||
"loginTailscaleDeviceName": "Device name:",
|
"loginTailscaleDeviceName": "Nom del dispositiu:",
|
||||||
"loginTailscaleOtherMethod": "Login with another method",
|
"loginTailscaleOtherMethod": "Iniciar la sessió amb un altre mètode",
|
||||||
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
|
"loginTailscaleSuccess": "Autenticat correctament amb Tailscale.",
|
||||||
"loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.",
|
"loginTailscaleFail": "Errada en autentificar amb Tailscale. Si us plau, intenteu-ho una altra vegada o feu servir un altre mètode d'autenticació.",
|
||||||
"logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device <code>{{deviceName}}</code>. Click the button below to logout.",
|
"logoutTailscaleSubtitle": "Heu iniciat sessió actualment amb Tailscale en el vostre dispositiu <code>{{deviceName}}</code>. Cliqueu el botó de sota per a tancar la sessió.",
|
||||||
"quickActionsLanguage": "Language",
|
"quickActionsLanguage": "Idioma",
|
||||||
"quickActionsTheme": "Theme",
|
"quickActionsTheme": "Tema",
|
||||||
"quickActionsThemeLight": "Light",
|
"quickActionsThemeLight": "Clar",
|
||||||
"quickActionsThemeDark": "Dark",
|
"quickActionsThemeDark": "Fosc",
|
||||||
"quickActionsThemeSystem": "System",
|
"quickActionsThemeSystem": "Sistema",
|
||||||
"quickActionsLogout": "Logout",
|
"quickActionsLogout": "Sortir de la sessió",
|
||||||
"quickActionsTitle": "Quick Actions",
|
"quickActionsTitle": "Accions ràpides",
|
||||||
"quickActionsProviderLocal": "Local",
|
"quickActionsProviderLocal": "Local",
|
||||||
"quickActionsProviderLDAP": "LDAP",
|
"quickActionsProviderLDAP": "LDAP",
|
||||||
"quickActionsProviderOAuth": "{{provider}} OAuth"
|
"quickActionsProviderOAuth": "{{provider}} OAuth"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ require (
|
|||||||
github.com/cenkalti/backoff/v5 v5.0.3
|
github.com/cenkalti/backoff/v5 v5.0.3
|
||||||
github.com/docker/docker v28.5.2+incompatible
|
github.com/docker/docker v28.5.2+incompatible
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4
|
github.com/go-jose/go-jose/v4 v4.1.5
|
||||||
github.com/go-ldap/ldap/v3 v3.4.14
|
github.com/go-ldap/ldap/v3 v3.4.14
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||||
@@ -23,14 +23,15 @@ require (
|
|||||||
github.com/tinyauthapp/paerser v0.0.0-20260410140347-85c3740d6298
|
github.com/tinyauthapp/paerser v0.0.0-20260410140347-85c3740d6298
|
||||||
github.com/weppos/publicsuffix-go v0.50.3
|
github.com/weppos/publicsuffix-go v0.50.3
|
||||||
go.uber.org/dig v1.19.0
|
go.uber.org/dig v1.19.0
|
||||||
golang.org/x/crypto v0.55.0
|
golang.org/x/crypto v0.56.0
|
||||||
golang.org/x/net v0.58.0
|
golang.org/x/net v0.58.0
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
golang.org/x/tools v0.49.0
|
golang.org/x/tools v0.49.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
k8s.io/api v0.37.0
|
||||||
k8s.io/apimachinery v0.37.0
|
k8s.io/apimachinery v0.37.0
|
||||||
k8s.io/client-go v0.37.0
|
k8s.io/client-go v0.37.0
|
||||||
modernc.org/sqlite v1.57.0
|
modernc.org/sqlite v1.58.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -74,13 +75,14 @@ require (
|
|||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.4 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/google/gnostic-models v0.7.1 // indirect
|
||||||
github.com/huandu/xstrings v1.5.0 // indirect
|
github.com/huandu/xstrings v1.5.0 // indirect
|
||||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
|
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
@@ -123,12 +125,12 @@ require (
|
|||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
go.opentelemetry.io/otel v1.45.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
go.opentelemetry.io/otel/metric v1.45.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
|
go.opentelemetry.io/otel/sdk v1.45.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
go.opentelemetry.io/otel/trace v1.45.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
@@ -145,9 +147,9 @@ require (
|
|||||||
k8s.io/klog/v2 v2.140.0 // indirect
|
k8s.io/klog/v2 v2.140.0 // indirect
|
||||||
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect
|
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect
|
||||||
k8s.io/utils v0.0.0-20260626114624-be93311217bd // indirect
|
k8s.io/utils v0.0.0-20260626114624-be93311217bd // indirect
|
||||||
modernc.org/libc v1.74.4 // indirect
|
modernc.org/libc v1.75.6 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.12.1 // indirect
|
||||||
rsc.io/qr v0.2.0 // indirect
|
rsc.io/qr v0.2.0 // indirect
|
||||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||||
|
|||||||
@@ -115,13 +115,13 @@ github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
|||||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
|
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU6eA=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
|
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
|
||||||
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
|
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
|
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
|
||||||
@@ -168,8 +168,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
|
|||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||||
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
|
||||||
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
@@ -329,20 +329,20 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
|
|||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
|
||||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
|
||||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
|
||||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
|
||||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
|
||||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
|
||||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||||
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
|
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
|
||||||
@@ -355,8 +355,8 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
|||||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||||
golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
|
golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
|
||||||
@@ -410,30 +410,30 @@ k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3d
|
|||||||
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
|
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
|
||||||
k8s.io/utils v0.0.0-20260626114624-be93311217bd h1:Ea7fgQ5we8Y9T0OX5o0dAHzQOBRI07D/dEYRaB9ZZEs=
|
k8s.io/utils v0.0.0-20260626114624-be93311217bd h1:Ea7fgQ5we8Y9T0OX5o0dAHzQOBRI07D/dEYRaB9ZZEs=
|
||||||
k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
||||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||||
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
|
||||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
|
||||||
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
|
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
|
modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0=
|
||||||
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -545,7 +547,9 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if client.ClientSecret != creds.ClientSecret {
|
clientSecretHash := sha256.Sum256([]byte(client.ClientSecret))
|
||||||
|
providedSecretHash := sha256.Sum256([]byte(creds.ClientSecret))
|
||||||
|
if subtle.ConstantTimeCompare(clientSecretHash[:], providedSecretHash[:]) != 1 {
|
||||||
controller.log.App.Warn().Str("clientId", creds.ClientID).Msg("Invalid client secret")
|
controller.log.App.Warn().Str("clientId", creds.ClientID).Msg("Invalid client secret")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"error": "invalid_client",
|
"error": "invalid_client",
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func NewDefaultConfiguration(runtimeEnv RuntimeEnv) *Config {
|
|||||||
UI: UIConfig{
|
UI: UIConfig{
|
||||||
Title: "Tinyauth",
|
Title: "Tinyauth",
|
||||||
ForgotPasswordMessage: "You can change your password by changing the configuration.",
|
ForgotPasswordMessage: "You can change your password by changing the configuration.",
|
||||||
BackgroundImage: "/background.jpg",
|
BackgroundImage: "/background.webp",
|
||||||
WarningsEnabled: true,
|
WarningsEnabled: true,
|
||||||
},
|
},
|
||||||
LDAP: LDAPConfig{
|
LDAP: LDAPConfig{
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func NewAccessControlsService(i AccessControlServiceInput) *AccessControlsServic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *AccessControlsService) ensureAscii(str string) bool {
|
func ensureAscii(str string) bool {
|
||||||
for i := 0; i < len(str); i++ {
|
for i := 0; i < len(str); i++ {
|
||||||
if str[i] > unicode.MaxASCII {
|
if str[i] > unicode.MaxASCII {
|
||||||
return false
|
return false
|
||||||
@@ -51,7 +51,7 @@ func (service *AccessControlsService) ensureAscii(str string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *AccessControlsService) normalizeDomain(domain string) string {
|
func normalizeDomain(domain string) string {
|
||||||
if host, _, err := net.SplitHostPort(domain); err == nil {
|
if host, _, err := net.SplitHostPort(domain); err == nil {
|
||||||
domain = host
|
domain = host
|
||||||
}
|
}
|
||||||
@@ -60,11 +60,11 @@ func (service *AccessControlsService) normalizeDomain(domain string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (service *AccessControlsService) getACLs(domain string, lookup func(locator func(name string, app *model.App) bool) error) (*model.App, error) {
|
func (service *AccessControlsService) getACLs(domain string, lookup func(locator func(name string, app *model.App) bool) error) (*model.App, error) {
|
||||||
if !service.ensureAscii(domain) {
|
if !ensureAscii(domain) {
|
||||||
return nil, errors.New("domain contains non-ascii characters")
|
return nil, errors.New("domain contains non-ascii characters")
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizedDomain := service.normalizeDomain(domain)
|
normalizedDomain := normalizeDomain(domain)
|
||||||
|
|
||||||
if !strings.HasSuffix(normalizedDomain, "."+service.runtime.CookieDomain) && normalizedDomain != service.runtime.CookieDomain {
|
if !strings.HasSuffix(normalizedDomain, "."+service.runtime.CookieDomain) && normalizedDomain != service.runtime.CookieDomain {
|
||||||
return nil, fmt.Errorf("domain does not match cookie domain, expected %s (or a subdomain), got %s", service.runtime.CookieDomain, domain)
|
return nil, fmt.Errorf("domain does not match cookie domain, expected %s (or a subdomain), got %s", service.runtime.CookieDomain, domain)
|
||||||
@@ -76,11 +76,11 @@ func (service *AccessControlsService) getACLs(domain string, lookup func(locator
|
|||||||
|
|
||||||
locatorFunc := func(name string, app *model.App) bool {
|
locatorFunc := func(name string, app *model.App) bool {
|
||||||
if app.Config.Domain != "" {
|
if app.Config.Domain != "" {
|
||||||
if !service.ensureAscii(app.Config.Domain) {
|
if !ensureAscii(app.Config.Domain) {
|
||||||
service.log.App.Warn().Str("name", name).Str("domain", app.Config.Domain).Msg("Domain contains non-ascii characters, skipping")
|
service.log.App.Warn().Str("name", name).Str("domain", app.Config.Domain).Msg("Domain contains non-ascii characters, skipping")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if normalizedDomain == service.normalizeDomain(app.Config.Domain) {
|
if normalizedDomain == normalizeDomain(app.Config.Domain) {
|
||||||
service.log.App.Debug().Str("name", name).Msg("Found matching container by domain")
|
service.log.App.Debug().Str("name", name).Msg("Found matching container by domain")
|
||||||
domainMatch = app
|
domainMatch = app
|
||||||
return true
|
return true
|
||||||
@@ -145,7 +145,9 @@ func (service *AccessControlsService) GetAccessControls(domain string) (*model.A
|
|||||||
|
|
||||||
// If we have a label provider configured, try to get ACLs from it
|
// If we have a label provider configured, try to get ACLs from it
|
||||||
if service.labelProvider != nil {
|
if service.labelProvider != nil {
|
||||||
return service.getACLs(domain, service.labelProvider.Lookup)
|
return service.getACLs(domain, func(locator func(name string, app *model.App) bool) error {
|
||||||
|
return service.labelProvider.Lookup(locator)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// No labels
|
// No labels
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func newMockProvider(acls map[string]model.App, shouldError bool) *mockProvider
|
|||||||
return &mockProvider{acls: acls, shouldError: shouldError}
|
return &mockProvider{acls: acls, shouldError: shouldError}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockProvider) Lookup(locator func(name string, app *model.App) bool) error {
|
func (m *mockProvider) Lookup(_ string, locator func(name string, app *model.App) bool) error {
|
||||||
if m.shouldError {
|
if m.shouldError {
|
||||||
return errors.New("mock error")
|
return errors.New("mock error")
|
||||||
}
|
}
|
||||||
@@ -153,7 +153,9 @@ func TestAccessControlsService(t *testing.T) {
|
|||||||
Config: &model.Config{},
|
Config: &model.Config{},
|
||||||
LabelProvider: mock,
|
LabelProvider: mock,
|
||||||
})
|
})
|
||||||
app, err := acls.getACLs(test.domain, mock.Lookup)
|
app, err := acls.getACLs(test.domain, func(locator func(name string, app *model.App) bool) error {
|
||||||
|
return mock.Lookup(test.domain, locator)
|
||||||
|
})
|
||||||
if test.errorFunc != nil {
|
if test.errorFunc != nil {
|
||||||
test.errorFunc(t, err)
|
test.errorFunc(t, err)
|
||||||
return
|
return
|
||||||
@@ -186,11 +188,14 @@ func TestAccessControlsService(t *testing.T) {
|
|||||||
// get acls should return an error when the provider fails
|
// get acls should return an error when the provider fails
|
||||||
mock := newMockProvider(map[string]model.App{}, true)
|
mock := newMockProvider(map[string]model.App{}, true)
|
||||||
acls := NewAccessControlsService(AccessControlServiceInput{
|
acls := NewAccessControlsService(AccessControlServiceInput{
|
||||||
Log: log,
|
Log: log,
|
||||||
Runtime: &runtime,
|
Runtime: &runtime,
|
||||||
Config: &model.Config{},
|
Config: &model.Config{},
|
||||||
|
LabelProvider: mock,
|
||||||
|
})
|
||||||
|
_, err := acls.getACLs("example.com", func(locator func(name string, app *model.App) bool) error {
|
||||||
|
return mock.Lookup("example.com", locator)
|
||||||
})
|
})
|
||||||
_, err := acls.getACLs("example.com", mock.Lookup)
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
|
|
||||||
// get acls should return an error when multiple apps with the same domain exist
|
// get acls should return an error when multiple apps with the same domain exist
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cenkalti/backoff/v5"
|
||||||
"github.com/steveiliop56/ding"
|
"github.com/steveiliop56/ding"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
|
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
|
||||||
@@ -14,6 +18,10 @@ import (
|
|||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrPingFailed = fmt.Errorf("failed to ping docker")
|
||||||
|
)
|
||||||
|
|
||||||
type DockerService struct {
|
type DockerService struct {
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
client *client.Client
|
client *client.Client
|
||||||
@@ -31,26 +39,54 @@ type DockerServiceInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewDockerService(i DockerServiceInput) (*DockerService, error) {
|
func NewDockerService(i DockerServiceInput) (*DockerService, error) {
|
||||||
client, err := client.NewClientWithOpts(client.FromEnv)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
client.NegotiateAPIVersion(i.Ctx)
|
|
||||||
|
|
||||||
_, err = client.Ping(i.Ctx)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
i.Log.App.Debug().Err(err).Msg("Docker not connected")
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
service := &DockerService{
|
service := &DockerService{
|
||||||
log: i.Log,
|
log: i.Log,
|
||||||
client: client,
|
|
||||||
context: i.Ctx,
|
context: i.Ctx,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
service.log.App.Debug().Msg("Attempting to connect to Docker")
|
||||||
|
|
||||||
|
if os.Getenv("DOCKER_HOST") == "" {
|
||||||
|
cli, err := service.connect()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrPingFailed) {
|
||||||
|
service.log.App.Debug().Msg("Docker not connected")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to connect to docker: %w", err)
|
||||||
|
}
|
||||||
|
service.client = cli
|
||||||
|
} else {
|
||||||
|
exp := backoff.NewExponentialBackOff()
|
||||||
|
exp.InitialInterval = 3 * time.Second
|
||||||
|
exp.RandomizationFactor = 0.1
|
||||||
|
exp.Multiplier = 1.5
|
||||||
|
exp.Reset()
|
||||||
|
|
||||||
|
operation := func() (*client.Client, error) {
|
||||||
|
if service.client != nil {
|
||||||
|
service.client.Close()
|
||||||
|
}
|
||||||
|
cli, err := service.connect()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cli, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cli, err := backoff.Retry(service.context, operation, backoff.WithBackOff(exp), backoff.WithMaxTries(3))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrPingFailed) {
|
||||||
|
service.log.App.Debug().Msg("Docker not connected after retrying")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to connect to docker after retrying: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
service.client = cli
|
||||||
|
}
|
||||||
|
|
||||||
service.isConnected = true
|
service.isConnected = true
|
||||||
service.log.App.Debug().Msg("Docker connected successfully")
|
service.log.App.Debug().Msg("Docker connected successfully")
|
||||||
|
|
||||||
@@ -59,6 +95,22 @@ func NewDockerService(i DockerServiceInput) (*DockerService, error) {
|
|||||||
return service, nil
|
return service, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (docker *DockerService) connect() (*client.Client, error) {
|
||||||
|
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = cli.Ping(docker.context)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrPingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
return cli, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (docker *DockerService) getContainers() ([]container.Summary, error) {
|
func (docker *DockerService) getContainers() ([]container.Summary, error) {
|
||||||
return docker.client.ContainerList(docker.context, container.ListOptions{})
|
return docker.client.ContainerList(docker.context, container.ListOptions{})
|
||||||
}
|
}
|
||||||
@@ -67,7 +119,10 @@ func (docker *DockerService) inspectContainer(containerId string) (container.Ins
|
|||||||
return docker.client.ContainerInspect(docker.context, containerId)
|
return docker.client.ContainerInspect(docker.context, containerId)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (docker *DockerService) Lookup(locator func(name string, app *model.App) bool) error {
|
// Lookup yields every app labelled on a running container. Container labels
|
||||||
|
// carry no routing information, so the domain cannot be used to narrow the
|
||||||
|
// results down and the caller is left to match them.
|
||||||
|
func (docker *DockerService) Lookup(_ string, locator func(name string, app *model.App) bool) error {
|
||||||
if !docker.isConnected {
|
if !docker.isConnected {
|
||||||
docker.log.App.Debug().Msg("Docker service not connected, returning empty labels")
|
docker.log.App.Debug().Msg("Docker service not connected, returning empty labels")
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||||
|
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||||
|
"github.com/tinyauthapp/tinyauth/pkg/apis/tinyauth/v1alpha1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type KubernetesCRDInput struct {
|
||||||
|
Log *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
type KubernetesCRDExtractor struct {
|
||||||
|
log *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewKubernetesCRDExtractor(i KubernetesCRDInput) *KubernetesCRDExtractor {
|
||||||
|
return &KubernetesCRDExtractor{
|
||||||
|
log: i.Log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *KubernetesCRDExtractor) Extract(app *v1alpha1.Application) ExtractionResult {
|
||||||
|
meta := &ResourceMeta{
|
||||||
|
Name: app.GetName(),
|
||||||
|
Namespace: app.GetNamespace(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ensureResourceMeta(meta) {
|
||||||
|
k.log.App.Warn().Str("namespace", meta.Namespace).Str("name", meta.Name).Msg("Resource has no namespace or name, skipping")
|
||||||
|
return ExtractionResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.Spec.Config.Domain == "" {
|
||||||
|
k.log.App.Warn().Str("name", meta.Name).Str("namespace", meta.Namespace).Msg("Application has no domain, skipping")
|
||||||
|
return ExtractionResult{
|
||||||
|
Meta: meta,
|
||||||
|
Apps: nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ensureAscii(app.Spec.Config.Domain) {
|
||||||
|
k.log.App.Warn().Str("name", meta.Name).Str("namespace", meta.Namespace).Str("domain", app.Spec.Config.Domain).Msg("Domain is invalid, skipping")
|
||||||
|
return ExtractionResult{
|
||||||
|
Meta: meta,
|
||||||
|
Apps: nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExtractionResult{
|
||||||
|
Meta: meta,
|
||||||
|
Apps: &map[string]model.App{
|
||||||
|
// Convert the CRD to the internal representation
|
||||||
|
meta.Name: app.Spec.ToInternalApp(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||||
|
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
|
||||||
|
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||||
|
networking "k8s.io/api/networking/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func hostMatchesHostname(host string, hostname string) bool {
|
||||||
|
host = normalizeDomain(host)
|
||||||
|
hostname = normalizeDomain(hostname)
|
||||||
|
if suffix, ok := strings.CutPrefix(host, "*."); ok {
|
||||||
|
return strings.HasSuffix(hostname, "."+suffix)
|
||||||
|
}
|
||||||
|
return host == hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostCoversName(host string, name string) bool {
|
||||||
|
host = strings.ToLower(host)
|
||||||
|
if strings.HasPrefix(host, "*.") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(host, strings.ToLower(name+"."))
|
||||||
|
}
|
||||||
|
|
||||||
|
type KubernetesIngressExtractor struct {
|
||||||
|
log *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
type KubernetesIngressExtractorInput struct {
|
||||||
|
Log *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewKubernetesIngressExtractor(i KubernetesIngressExtractorInput) *KubernetesIngressExtractor {
|
||||||
|
return &KubernetesIngressExtractor{
|
||||||
|
log: i.Log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *KubernetesIngressExtractor) getPaths(rule networking.IngressRule) []string {
|
||||||
|
var paths []string
|
||||||
|
|
||||||
|
if rule.HTTP == nil {
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range rule.HTTP.Paths {
|
||||||
|
paths = append(paths, path.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *KubernetesIngressExtractor) getHosts(rules []networking.IngressRule) []string {
|
||||||
|
var hosts []string
|
||||||
|
|
||||||
|
for _, rule := range rules {
|
||||||
|
hosts = append(hosts, rule.Host)
|
||||||
|
paths := k.getPaths(rule)
|
||||||
|
|
||||||
|
if len(paths) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(paths, "/") {
|
||||||
|
k.log.App.Warn().Strs("hosts", hosts).Strs("paths", paths).Msg("Ingress rule does not contain a catch-all path, another ingress may be able to bypass auth checks if it routes the same host with a different path. Consider adding a catch-all path to this rule to ensure auth checks are applied to all paths for this host.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hosts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *KubernetesIngressExtractor) Extract(ingress *networking.Ingress) ExtractionResult {
|
||||||
|
meta := &ResourceMeta{
|
||||||
|
Name: ingress.GetName(),
|
||||||
|
Namespace: ingress.GetNamespace(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ensureResourceMeta(meta) {
|
||||||
|
k.log.App.Warn().Str("namespace", meta.Namespace).Str("name", meta.Name).Msg("Resource has no namespace or name, skipping")
|
||||||
|
return ExtractionResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
annotations := ingress.GetAnnotations()
|
||||||
|
hosts := k.getHosts(ingress.Spec.Rules)
|
||||||
|
|
||||||
|
if len(hosts) == 0 {
|
||||||
|
k.log.App.Warn().Str("namespace", meta.Namespace).Str("name", meta.Name).Msg("No hosts found in resource, skipping")
|
||||||
|
return ExtractionResult{
|
||||||
|
Meta: meta,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps")
|
||||||
|
if err != nil {
|
||||||
|
k.log.App.Warn().Err(err).Str("namespace", meta.Namespace).Str("name", meta.Name).Msg("Failed to decode resource labels, skipping")
|
||||||
|
return ExtractionResult{
|
||||||
|
Meta: meta,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apps := make(map[string]model.App)
|
||||||
|
|
||||||
|
for name, config := range labels.Apps {
|
||||||
|
if config.Config.Domain != "" {
|
||||||
|
if !ensureAscii(config.Config.Domain) {
|
||||||
|
k.log.App.Warn().Err(err).Str("namespace", meta.Namespace).Str("name", meta.Name).Str("domain", config.Config.Domain).Msg("Domain is invalid, matching will rely on app name")
|
||||||
|
} else {
|
||||||
|
if slices.ContainsFunc(hosts, func(host string) bool {
|
||||||
|
return hostMatchesHostname(host, config.Config.Domain)
|
||||||
|
}) {
|
||||||
|
apps[name] = config
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.ContainsFunc(hosts, func(host string) bool {
|
||||||
|
return hostCoversName(host, name)
|
||||||
|
}) {
|
||||||
|
apps[name] = config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExtractionResult{
|
||||||
|
Meta: meta,
|
||||||
|
Apps: &apps,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,43 +3,104 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/steveiliop56/ding"
|
"github.com/steveiliop56/ding"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||||
"github.com/tinyauthapp/tinyauth/pkg/validators"
|
"github.com/tinyauthapp/tinyauth/pkg/apis/tinyauth/v1alpha1"
|
||||||
"go.uber.org/dig"
|
"go.uber.org/dig"
|
||||||
|
networking "k8s.io/api/networking/v1"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
"k8s.io/apimachinery/pkg/watch"
|
"k8s.io/apimachinery/pkg/watch"
|
||||||
"k8s.io/client-go/dynamic"
|
"k8s.io/client-go/dynamic"
|
||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ingressEntry struct {
|
type watchedResource struct {
|
||||||
name string
|
gvr schema.GroupVersionResource
|
||||||
app model.App
|
typ ResourceType
|
||||||
}
|
}
|
||||||
|
|
||||||
type ingressKey struct {
|
func (w watchedResource) pretty() string {
|
||||||
namespace string
|
return w.gvr.Group + "/" + w.gvr.Version + "/" + w.gvr.Resource
|
||||||
name string
|
}
|
||||||
|
|
||||||
|
func ensureResourceMeta(meta *ResourceMeta) bool {
|
||||||
|
return meta.Name != "" && meta.Namespace != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourceMeta struct {
|
||||||
|
Name string
|
||||||
|
Namespace string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtractionResult struct {
|
||||||
|
Meta *ResourceMeta
|
||||||
|
Apps *map[string]model.App
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourceType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ResourceTypeIngress ResourceType = "ingress"
|
||||||
|
ResourceTypeCRD ResourceType = "crd"
|
||||||
|
)
|
||||||
|
|
||||||
|
var supportedResources = []watchedResource{
|
||||||
|
{
|
||||||
|
gvr: schema.GroupVersionResource{
|
||||||
|
Group: "networking.k8s.io",
|
||||||
|
Version: "v1",
|
||||||
|
Resource: "ingresses",
|
||||||
|
},
|
||||||
|
typ: ResourceTypeIngress,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type typedItem struct {
|
||||||
|
typ ResourceType
|
||||||
|
ingress *networking.Ingress
|
||||||
|
crd *v1alpha1.Application
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertFromUnstructured[T any](obj *unstructured.Unstructured) (*T, error) {
|
||||||
|
var typed *T
|
||||||
|
err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &typed)
|
||||||
|
if err != nil {
|
||||||
|
var zero *T
|
||||||
|
return zero, fmt.Errorf("failed to convert ingress to typed object: %w", err)
|
||||||
|
}
|
||||||
|
return typed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ti *typedItem) fromUnstructured(typ ResourceType, obj *unstructured.Unstructured) (*typedItem, error) {
|
||||||
|
switch typ {
|
||||||
|
case ResourceTypeIngress:
|
||||||
|
typed, err := convertFromUnstructured[networking.Ingress](obj)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &typedItem{
|
||||||
|
typ: ResourceTypeIngress,
|
||||||
|
ingress: typed,
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown resource type %s", typ)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type KubernetesService struct {
|
type KubernetesService struct {
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
|
|
||||||
client dynamic.Interface
|
apps map[ResourceMeta]map[string]model.App
|
||||||
connected bool
|
client dynamic.Interface
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
ingressEntries map[ingressKey][]ingressEntry
|
connected bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type KubernetesServiceInput struct {
|
type KubernetesServiceInput struct {
|
||||||
@@ -61,32 +122,37 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error)
|
|||||||
return nil, fmt.Errorf("failed to create kubernetes client: %w", err)
|
return nil, fmt.Errorf("failed to create kubernetes client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
gvr := schema.GroupVersionResource{
|
|
||||||
Group: "networking.k8s.io",
|
|
||||||
Version: "v1",
|
|
||||||
Resource: "ingresses",
|
|
||||||
}
|
|
||||||
|
|
||||||
accessCtx, accessCancel := context.WithTimeout(i.Ctx, 5*time.Second)
|
|
||||||
defer accessCancel()
|
|
||||||
|
|
||||||
_, err = client.Resource(gvr).List(accessCtx, metav1.ListOptions{Limit: 1})
|
|
||||||
if err != nil {
|
|
||||||
i.Log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to access Ingress API, Kubernetes label provider will be disabled")
|
|
||||||
return nil, fmt.Errorf("failed to access ingress api: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
i.Log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Successfully accessed Ingress API, starting watcher")
|
|
||||||
|
|
||||||
service := &KubernetesService{
|
service := &KubernetesService{
|
||||||
log: i.Log,
|
log: i.Log,
|
||||||
client: client,
|
client: client,
|
||||||
ingressEntries: make(map[ingressKey][]ingressEntry),
|
apps: make(map[ResourceMeta]map[string]model.App),
|
||||||
}
|
}
|
||||||
|
|
||||||
i.Ding.Go(func(ctx context.Context) {
|
watchedGVRs := make(map[string]bool)
|
||||||
service.watchGVR(gvr, ctx)
|
|
||||||
}, ding.RingMajor)
|
for _, res := range supportedResources {
|
||||||
|
ctx, cancel := context.WithTimeout(i.Ctx, 5*time.Second)
|
||||||
|
_, err := client.Resource(res.gvr).List(ctx, metav1.ListOptions{Limit: 1})
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// The CRD may not be available yet, so we'll fall back to ingress
|
||||||
|
i.Log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Failed to access resource, skipping watcher")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
i.Log.App.Debug().Str("res", res.pretty()).Msg("Successfully accessed resource, starting watcher")
|
||||||
|
|
||||||
|
i.Ding.Go(func(ctx context.Context) {
|
||||||
|
service.watchGVR(res, ctx)
|
||||||
|
}, ding.RingMajor)
|
||||||
|
|
||||||
|
watchedGVRs[res.gvr.Resource] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(watchedGVRs) == 0 {
|
||||||
|
return nil, fmt.Errorf("failed to access any supported kubernetes api (ingresses, httproutes, grpcroutes)")
|
||||||
|
}
|
||||||
|
|
||||||
service.connected = true
|
service.connected = true
|
||||||
i.Log.App.Debug().Msg("Kubernetes label provider started successfully")
|
i.Log.App.Debug().Msg("Kubernetes label provider started successfully")
|
||||||
@@ -94,182 +160,93 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error)
|
|||||||
return service, nil
|
return service, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KubernetesService) addIngressEntries(key ingressKey, entries []ingressEntry) {
|
func (k *KubernetesService) addResource(result ExtractionResult) {
|
||||||
k.mu.Lock()
|
k.mu.Lock()
|
||||||
defer k.mu.Unlock()
|
defer k.mu.Unlock()
|
||||||
k.ingressEntries[key] = entries
|
k.apps[*result.Meta] = *result.Apps
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KubernetesService) removeIngress(key ingressKey) {
|
func (k *KubernetesService) removeResource(meta ResourceMeta) {
|
||||||
k.mu.Lock()
|
k.mu.Lock()
|
||||||
defer k.mu.Unlock()
|
defer k.mu.Unlock()
|
||||||
delete(k.ingressEntries, key)
|
delete(k.apps, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KubernetesService) getEntry(locator func(name string, app *model.App) bool) {
|
func (k *KubernetesService) getEntry(locator func(name string, app *model.App) bool) {
|
||||||
k.mu.RLock()
|
k.mu.RLock()
|
||||||
defer k.mu.RUnlock()
|
defer k.mu.RUnlock()
|
||||||
|
|
||||||
// O(n^2) is not great but the number of ingress entries is expected to be small
|
for _, apps := range k.apps {
|
||||||
for _, entries := range k.ingressEntries {
|
for name, app := range apps {
|
||||||
for _, entry := range entries {
|
if ok := locator(name, &app); ok {
|
||||||
if ok := locator(entry.name, &entry.app); ok {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KubernetesService) extractPaths(rule map[string]any) ([]string, error) {
|
func (k *KubernetesService) updateFromItem(res watchedResource, typedItem *typedItem) {
|
||||||
http, found, err := unstructured.NestedMap(rule, "http")
|
if typedItem == nil {
|
||||||
if err != nil {
|
k.log.App.Warn().Str("res", res.pretty()).Msg("Resource is nil, skipping")
|
||||||
return nil, fmt.Errorf("reading http from rule: %w", err)
|
return
|
||||||
}
|
}
|
||||||
if !found {
|
|
||||||
return nil, nil
|
var result ExtractionResult
|
||||||
}
|
|
||||||
paths, found, err := unstructured.NestedSlice(http, "paths")
|
switch typedItem.typ {
|
||||||
if err != nil {
|
case ResourceTypeIngress:
|
||||||
return nil, fmt.Errorf("reading http.paths: %w", err)
|
if typedItem.ingress == nil {
|
||||||
}
|
k.log.App.Warn().Str("res", res.pretty()).Msg("Ingress is nil, skipping")
|
||||||
if !found {
|
return
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
var result []string
|
|
||||||
for _, p := range paths {
|
|
||||||
path, ok := p.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if p, ok := path["path"].(string); ok && p != "" {
|
extractor := NewKubernetesIngressExtractor(KubernetesIngressExtractorInput{
|
||||||
result = append(result, p)
|
Log: k.log,
|
||||||
|
})
|
||||||
|
result = extractor.Extract(typedItem.ingress)
|
||||||
|
case ResourceTypeCRD:
|
||||||
|
if typedItem.crd == nil {
|
||||||
|
k.log.App.Warn().Str("res", res.pretty()).Msg("CRD is nil, skipping")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
extractor := NewKubernetesCRDExtractor(KubernetesCRDInput{
|
||||||
|
Log: k.log,
|
||||||
|
})
|
||||||
|
result = extractor.Extract(typedItem.crd)
|
||||||
}
|
}
|
||||||
return result, nil
|
|
||||||
|
if result.Apps == nil {
|
||||||
|
k.log.App.Warn().Str("res", res.pretty()).Msg("Failed to extract resource, skipping")
|
||||||
|
if result.Meta != nil {
|
||||||
|
k.removeResource(*result.Meta)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
k.addResource(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KubernetesService) extractHosts(item *unstructured.Unstructured) ([]string, error) {
|
func (k *KubernetesService) resyncGVR(res watchedResource, ctx context.Context) error {
|
||||||
rules, found, err := unstructured.NestedSlice(item.Object, "spec", "rules")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("reading spec.rules: %w", err)
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
var hosts []string
|
|
||||||
for _, r := range rules {
|
|
||||||
rule, ok := r.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if host, ok := rule["host"].(string); ok && host != "" {
|
|
||||||
hosts = append(hosts, host)
|
|
||||||
}
|
|
||||||
paths, err := k.extractPaths(rule)
|
|
||||||
if err != nil {
|
|
||||||
// This is purely to warn users
|
|
||||||
// It doesn't affect our ability to extract hosts, so we won't fail the whole operation
|
|
||||||
k.log.App.Warn().Err(err).Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Msg("Failed to extract paths from ingress rule")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(paths) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !slices.Contains(paths, "/") {
|
|
||||||
k.log.App.Warn().Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Strs("paths", paths).Msg("Ingress rule does not contain a catch-all path, another ingress may be able to bypass auth checks if it routes the same host with a different path. Consider adding a catch-all path to this rule to ensure auth checks are applied to all paths for this host.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
k.log.App.Trace().Strs("hosts", hosts).Msg("Extracted hosts from ingress rules")
|
|
||||||
return hosts, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
|
|
||||||
key := ingressKey{
|
|
||||||
namespace: item.GetNamespace(),
|
|
||||||
name: item.GetName(),
|
|
||||||
}
|
|
||||||
|
|
||||||
annotations := item.GetAnnotations()
|
|
||||||
if annotations == nil {
|
|
||||||
k.removeIngress(key)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hosts, err := k.extractHosts(item)
|
|
||||||
if err != nil {
|
|
||||||
k.removeIngress(key)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(hosts) == 0 {
|
|
||||||
k.log.App.Warn().Str("namespace", key.namespace).Str("name", key.name).Msg("No hosts found in ingress, skipping")
|
|
||||||
k.removeIngress(key)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps")
|
|
||||||
if err != nil {
|
|
||||||
k.log.App.Warn().Err(err).Str("namespace", key.namespace).Str("name", key.name).Msg("Failed to decode ingress labels, skipping")
|
|
||||||
k.removeIngress(key)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var entries []ingressEntry
|
|
||||||
|
|
||||||
v := validators.NewDomainValidator(validators.DomainValidatorOptions{})
|
|
||||||
|
|
||||||
for name, config := range labels.Apps {
|
|
||||||
if config.Config.Domain != "" {
|
|
||||||
hostname, err := v.SafeHostname(config.Config.Domain)
|
|
||||||
if err != nil {
|
|
||||||
k.log.App.Warn().Err(err).Str("namespace", key.namespace).Str("name", key.name).Str("domain", config.Config.Domain).Msg("Domain is invalid, matching will rely on app name")
|
|
||||||
} else if slices.Contains(hosts, hostname) {
|
|
||||||
entries = append(entries, ingressEntry{
|
|
||||||
name: name,
|
|
||||||
app: config,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, host := range hosts {
|
|
||||||
if strings.HasPrefix(strings.ToLower(host), strings.ToLower(name+".")) {
|
|
||||||
entries = append(entries, ingressEntry{
|
|
||||||
name: name,
|
|
||||||
app: config,
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(entries) == 0 {
|
|
||||||
k.removeIngress(key)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
k.addIngressEntries(key, entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource, ctx context.Context) error {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
list, err := k.client.Resource(gvr).List(ctx, metav1.ListOptions{})
|
list, err := k.client.Resource(res.gvr).List(ctx, metav1.ListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to list resources for resync")
|
k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Failed to list resources for resync")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for i := range list.Items {
|
for _, item := range list.Items {
|
||||||
k.updateFromItem(&list.Items[i])
|
newTypedItem, err := new(typedItem).fromUnstructured(res.typ, &item)
|
||||||
|
if err != nil {
|
||||||
|
k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Failed to decode resource, skipping")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k.updateFromItem(res, newTypedItem)
|
||||||
}
|
}
|
||||||
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Int("count", len(list.Items)).Msg("Resync complete")
|
k.log.App.Debug().Str("res", res.pretty()).Int("count", len(list.Items)).Msg("Resync complete")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// runWatcher drains events from an active watcher until it closes or the context is done.
|
func (k *KubernetesService) runWatcher(res watchedResource, w watch.Interface, resyncTicker *time.Ticker, ctx context.Context) bool {
|
||||||
// Returns true if the caller should restart the watcher, false if it should exit.
|
|
||||||
func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.Interface, resyncTicker *time.Ticker, ctx context.Context) bool {
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -277,62 +254,62 @@ func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.
|
|||||||
return false
|
return false
|
||||||
case event, ok := <-w.ResultChan():
|
case event, ok := <-w.ResultChan():
|
||||||
if !ok {
|
if !ok {
|
||||||
k.log.App.Warn().Str("api", gvr.GroupVersion().String()).Msg("Watcher channel closed, restarting watcher")
|
k.log.App.Warn().Str("res", res.pretty()).Msg("Watcher channel closed, restarting watcher")
|
||||||
w.Stop()
|
w.Stop()
|
||||||
time.Sleep(5 * time.Second)
|
time.Sleep(5 * time.Second)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
item, ok := event.Object.(*unstructured.Unstructured)
|
item, ok := event.Object.(*unstructured.Unstructured)
|
||||||
if !ok {
|
if !ok {
|
||||||
k.log.App.Warn().Str("api", gvr.GroupVersion().String()).Msg("Received unexpected event object, skipping")
|
k.log.App.Warn().Str("res", res.pretty()).Msg("Received unexpected event object, skipping")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newTypedItem, err := new(typedItem).fromUnstructured(res.typ, item)
|
||||||
|
if err != nil {
|
||||||
|
k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Failed to decode resource, skipping")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch event.Type {
|
switch event.Type {
|
||||||
case watch.Added, watch.Modified:
|
case watch.Added, watch.Modified, watch.Deleted:
|
||||||
k.updateFromItem(item)
|
k.updateFromItem(res, newTypedItem)
|
||||||
case watch.Deleted:
|
|
||||||
k.removeIngress(ingressKey{
|
|
||||||
namespace: item.GetNamespace(),
|
|
||||||
name: item.GetName(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
case <-resyncTicker.C:
|
case <-resyncTicker.C:
|
||||||
if err := k.resyncGVR(gvr, ctx); err != nil {
|
if err := k.resyncGVR(res, ctx); err != nil {
|
||||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed during watcher run")
|
k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Periodic resync failed during watcher run")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource, ctx context.Context) {
|
func (k *KubernetesService) watchGVR(res watchedResource, ctx context.Context) {
|
||||||
resyncTicker := time.NewTicker(5 * time.Minute)
|
resyncTicker := time.NewTicker(5 * time.Minute)
|
||||||
defer resyncTicker.Stop()
|
defer resyncTicker.Stop()
|
||||||
|
|
||||||
if err := k.resyncGVR(gvr, ctx); err != nil {
|
if err := k.resyncGVR(res, ctx); err != nil {
|
||||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Initial resync failed, will retry")
|
k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Initial resync failed, will retry")
|
||||||
time.Sleep(30 * time.Second)
|
time.Sleep(30 * time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Shutting down kubernetes watcher")
|
k.log.App.Debug().Str("res", res.pretty()).Msg("Shutting down kubernetes watcher")
|
||||||
return
|
return
|
||||||
case <-resyncTicker.C:
|
case <-resyncTicker.C:
|
||||||
if err := k.resyncGVR(gvr, ctx); err != nil {
|
if err := k.resyncGVR(res, ctx); err != nil {
|
||||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed, will retry")
|
k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Periodic resync failed, will retry")
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
watcher, err := k.client.Resource(gvr).Watch(ctx, metav1.ListOptions{})
|
watcher, err := k.client.Resource(res.gvr).Watch(ctx, metav1.ListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to start watcher, will retry")
|
k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Failed to start watcher, will retry")
|
||||||
cancel()
|
cancel()
|
||||||
time.Sleep(10 * time.Second)
|
time.Sleep(10 * time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Watcher started successfully")
|
k.log.App.Debug().Str("res", res.pretty()).Msg("Watcher started successfully")
|
||||||
if !k.runWatcher(gvr, watcher, resyncTicker, ctx) {
|
if !k.runWatcher(res, watcher, resyncTicker, ctx) {
|
||||||
cancel()
|
cancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,676 +4,238 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||||
|
networking "k8s.io/api/networking/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestKubernetesService(t *testing.T) {
|
func watchedResourceForTest(t *testing.T, typ ResourceType) watchedResource {
|
||||||
|
t.Helper()
|
||||||
|
for _, resource := range supportedResources {
|
||||||
|
if resource.typ == typ {
|
||||||
|
return resource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("unsupported resource type %q", typ)
|
||||||
|
return watchedResource{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newKubernetesServiceForTest(log *logger.Logger) *KubernetesService {
|
||||||
|
service := &KubernetesService{
|
||||||
|
apps: make(map[resourceKey]routedApps),
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
service.extractors.ingress = NewKubernetesIngressExtractor(KubernetesIngressExtractorInput{Log: log})
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIngress(name string, annotations map[string]string, hosts ...string) *typedItem {
|
||||||
|
rules := make([]networking.IngressRule, 0, len(hosts))
|
||||||
|
for _, host := range hosts {
|
||||||
|
rules = append(rules, networking.IngressRule{Host: host})
|
||||||
|
}
|
||||||
|
return &typedItem{
|
||||||
|
typ: ResourceTypeIngress,
|
||||||
|
ingress: &networking.Ingress{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default", Annotations: annotations},
|
||||||
|
Spec: networking.IngressSpec{Rules: rules},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupApp(service *KubernetesService, domain string) *model.App {
|
||||||
|
var app *model.App
|
||||||
|
service.getEntry(domain, func(name string, candidate *model.App) bool {
|
||||||
|
if candidate.Config.Domain == domain || strings.HasPrefix(domain, name+".") {
|
||||||
|
app = candidate
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKubernetesServiceUpdateFromItem(t *testing.T) {
|
||||||
log := logger.NewLogger().WithTestConfig()
|
log := logger.NewLogger().WithTestConfig()
|
||||||
log.Init()
|
log.Init()
|
||||||
|
|
||||||
type testCase struct {
|
tests := []struct {
|
||||||
description string
|
name string
|
||||||
run func(t *testing.T, svc *KubernetesService)
|
resource ResourceType
|
||||||
|
item *typedItem
|
||||||
|
domain string
|
||||||
|
wantConfigDomain string
|
||||||
|
allow string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Ingress matches a configured domain",
|
||||||
|
resource: ResourceTypeIngress,
|
||||||
|
item: testIngress("ingress", map[string]string{
|
||||||
|
"tinyauth.apps.dashboard.config.domain": "dashboard.example.com",
|
||||||
|
"tinyauth.apps.dashboard.users.allow": "alice",
|
||||||
|
}, "dashboard.example.com"),
|
||||||
|
domain: "dashboard.example.com", wantConfigDomain: "dashboard.example.com", allow: "alice",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Ingress matches an app name case insensitively",
|
||||||
|
resource: ResourceTypeIngress,
|
||||||
|
item: testIngress("ingress", map[string]string{
|
||||||
|
"tinyauth.apps.dashboard.users.allow": "alice",
|
||||||
|
}, "Dashboard.example.com"),
|
||||||
|
domain: "dashboard.example.com", allow: "alice",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
tests := []testCase{
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
service := newKubernetesServiceForTest(log)
|
||||||
|
service.updateFromItem(watchedResourceForTest(t, test.resource), test.item)
|
||||||
|
|
||||||
|
app := lookupApp(service, test.domain)
|
||||||
|
require.NotNil(t, app)
|
||||||
|
assert.Equal(t, test.allow, app.Users.Allow)
|
||||||
|
assert.Equal(t, test.wantConfigDomain, app.Config.Domain)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKubernetesServiceUpdateFromItemRemovesStaleEntries(t *testing.T) {
|
||||||
|
log := logger.NewLogger().WithTestConfig()
|
||||||
|
log.Init()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
resource ResourceType
|
||||||
|
item *typedItem
|
||||||
|
}{
|
||||||
|
{"Ingress without annotations", ResourceTypeIngress, testIngress("route", nil, "app.example.com")},
|
||||||
|
{"Ingress without hosts", ResourceTypeIngress, testIngress("route", map[string]string{"tinyauth.apps.app.users.allow": "alice"})},
|
||||||
|
{"Ingress with invalid annotations", ResourceTypeIngress, testIngress("route", map[string]string{"tinyauth.apps.app.users.break": "invalid"}, "app.example.com")},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
service := newKubernetesServiceForTest(log)
|
||||||
|
key := resourceKey{typ: test.resource, namespace: "default", name: "route"}
|
||||||
|
service.addResourceEntries(key, []string{"app.example.com"}, []resourceEntry{{
|
||||||
|
name: "app",
|
||||||
|
app: model.App{Config: model.AppConfig{Domain: "app.example.com"}},
|
||||||
|
}})
|
||||||
|
|
||||||
|
service.updateFromItem(watchedResourceForTest(t, test.resource), test.item)
|
||||||
|
assert.Nil(t, lookupApp(service, "app.example.com"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypedItemFromUnstructured(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
resource ResourceType
|
||||||
|
item unstructured.Unstructured
|
||||||
|
assert func(t *testing.T, item *typedItem)
|
||||||
|
}{
|
||||||
{
|
{
|
||||||
description: "Cache by domain returns app and misses unknown domain",
|
name: "Ingress",
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
resource: ResourceTypeIngress,
|
||||||
app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}}
|
item: unstructured.Unstructured{Object: map[string]any{
|
||||||
svc.addIngressEntries(ingressKey{
|
"metadata": map[string]any{"name": "ingress", "namespace": "default"},
|
||||||
namespace: "default",
|
"spec": map[string]any{"rules": []any{map[string]any{"host": "app.example.com"}}},
|
||||||
name: "my-ingress",
|
}},
|
||||||
}, []ingressEntry{
|
assert: func(t *testing.T, item *typedItem) {
|
||||||
{
|
require.NotNil(t, item.ingress)
|
||||||
app: app,
|
assert.Equal(t, "app.example.com", item.ingress.Spec.Rules[0].Host)
|
||||||
name: "foo",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
var got *model.App
|
|
||||||
svc.getEntry(func(name string, app *model.App) bool {
|
|
||||||
if app.Config.Domain == "foo.example.com" {
|
|
||||||
got = app
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
require.NotNil(t, got)
|
|
||||||
assert.Equal(t, "foo.example.com", got.Config.Domain)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "RemoveIngress clears domain and app name entries",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}}
|
|
||||||
svc.addIngressEntries(ingressKey{
|
|
||||||
namespace: "default",
|
|
||||||
name: "my-ingress",
|
|
||||||
}, []ingressEntry{
|
|
||||||
{
|
|
||||||
app: app,
|
|
||||||
name: "foo",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
var got *model.App
|
|
||||||
svc.getEntry(func(name string, app *model.App) bool {
|
|
||||||
if app.Config.Domain == "foo.example.com" {
|
|
||||||
got = app
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
require.NotNil(t, got)
|
|
||||||
assert.Equal(t, "foo.example.com", got.Config.Domain)
|
|
||||||
|
|
||||||
got = nil
|
|
||||||
svc.removeIngress(ingressKey{
|
|
||||||
namespace: "default",
|
|
||||||
name: "my-ingress",
|
|
||||||
})
|
|
||||||
|
|
||||||
svc.getEntry(func(name string, app *model.App) bool {
|
|
||||||
if app.Config.Domain == "foo.example.com" {
|
|
||||||
got = app
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
assert.Nil(t, got)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "AddIngressApps replaces stale entries for the same ingress",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
old := model.App{Config: model.AppConfig{Domain: "old.example.com"}}
|
|
||||||
svc.addIngressEntries(ingressKey{
|
|
||||||
namespace: "default",
|
|
||||||
name: "my-ingress",
|
|
||||||
}, []ingressEntry{
|
|
||||||
{
|
|
||||||
app: old,
|
|
||||||
name: "foo",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
updated := model.App{Config: model.AppConfig{Domain: "new.example.com"}}
|
|
||||||
svc.addIngressEntries(ingressKey{
|
|
||||||
namespace: "default",
|
|
||||||
name: "my-ingress",
|
|
||||||
}, []ingressEntry{
|
|
||||||
{
|
|
||||||
app: updated,
|
|
||||||
name: "foo",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
var got *model.App
|
|
||||||
svc.getEntry(func(name string, app *model.App) bool {
|
|
||||||
if app.Config.Domain == "old.example.com" {
|
|
||||||
got = app
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
assert.Nil(t, got)
|
|
||||||
|
|
||||||
svc.getEntry(func(name string, app *model.App) bool {
|
|
||||||
if app.Config.Domain == "new.example.com" {
|
|
||||||
got = app
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
require.NotNil(t, got)
|
|
||||||
assert.Equal(t, "new.example.com", got.Config.Domain)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "GetLabels returns app from cache when connected",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
svc.connected = true
|
|
||||||
|
|
||||||
app := model.App{Config: model.AppConfig{Domain: "hit.example.com"}}
|
|
||||||
svc.addIngressEntries(ingressKey{
|
|
||||||
namespace: "default",
|
|
||||||
name: "my-ingress",
|
|
||||||
}, []ingressEntry{
|
|
||||||
{
|
|
||||||
app: app,
|
|
||||||
name: "foo",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
var got *model.App
|
|
||||||
err := svc.Lookup(func(name string, app *model.App) bool {
|
|
||||||
if app.Config.Domain == "hit.example.com" {
|
|
||||||
got = app
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, got)
|
|
||||||
assert.Equal(t, "hit.example.com", got.Config.Domain)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "GetLabels returns empty app on cache miss when started",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
svc.connected = true
|
|
||||||
|
|
||||||
var got *model.App
|
|
||||||
err := svc.Lookup(func(name string, app *model.App) bool {
|
|
||||||
if app.Config.Domain == "notfound.example.com" {
|
|
||||||
got = app
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Nil(t, got)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "GetLabels resolves app by app name",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
svc.connected = true
|
|
||||||
|
|
||||||
app := model.App{Path: model.AppPath{Allow: "/foo"}}
|
|
||||||
svc.addIngressEntries(ingressKey{
|
|
||||||
namespace: "default",
|
|
||||||
name: "my-ingress",
|
|
||||||
}, []ingressEntry{
|
|
||||||
{
|
|
||||||
app: app,
|
|
||||||
name: "foo",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
var got *model.App
|
|
||||||
err := svc.Lookup(func(name string, app *model.App) bool {
|
|
||||||
if strings.HasPrefix("foo.internal.example.com", "foo.") {
|
|
||||||
got = app
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, got)
|
|
||||||
assert.Equal(t, "/foo", got.Path.Allow)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "GetLabels returns empty app when service not yet started",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
var got *model.App
|
|
||||||
err := svc.Lookup(func(name string, app *model.App) bool {
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Nil(t, got)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "UpdateFromItem parses annotations and populates cache",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
item := unstructured.Unstructured{}
|
|
||||||
item.SetNamespace("default")
|
|
||||||
item.SetName("test-ingress")
|
|
||||||
item.SetAnnotations(map[string]string{
|
|
||||||
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
|
|
||||||
"tinyauth.apps.myapp.users.allow": "alice",
|
|
||||||
})
|
|
||||||
item.Object["spec"] = map[string]any{
|
|
||||||
"rules": []any{
|
|
||||||
map[string]any{
|
|
||||||
"host": "myapp.example.com",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
svc.updateFromItem(&item)
|
|
||||||
|
|
||||||
var got *model.App
|
|
||||||
svc.getEntry(func(name string, app *model.App) bool {
|
|
||||||
if app.Config.Domain == "myapp.example.com" {
|
|
||||||
got = app
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
require.NotNil(t, got)
|
|
||||||
assert.Equal(t, "myapp.example.com", got.Config.Domain)
|
|
||||||
assert.Equal(t, "alice", got.Users.Allow)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Update from item skips annotations with no hosts",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
item := unstructured.Unstructured{}
|
|
||||||
item.SetNamespace("default")
|
|
||||||
item.SetName("test-ingress")
|
|
||||||
item.SetAnnotations(map[string]string{
|
|
||||||
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
|
|
||||||
})
|
|
||||||
|
|
||||||
svc.updateFromItem(&item)
|
|
||||||
|
|
||||||
var got *model.App
|
|
||||||
svc.getEntry(func(name string, app *model.App) bool {
|
|
||||||
if app.Config.Domain == "myapp.example.com" {
|
|
||||||
got = app
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
assert.Nil(t, got)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "UpdateFromItem fails when label parsing fails",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
item := unstructured.Unstructured{}
|
|
||||||
item.SetNamespace("default")
|
|
||||||
item.SetName("test-ingress")
|
|
||||||
item.SetAnnotations(map[string]string{
|
|
||||||
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
|
|
||||||
"tinyauth.apps.myapp.users.break": "i-dont-exist",
|
|
||||||
})
|
|
||||||
item.Object["spec"] = map[string]any{
|
|
||||||
"rules": []any{
|
|
||||||
map[string]any{
|
|
||||||
"host": "myapp.example.com",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
svc.updateFromItem(&item)
|
|
||||||
|
|
||||||
var got *model.App
|
|
||||||
svc.getEntry(func(name string, app *model.App) bool {
|
|
||||||
if app.Config.Domain == "myapp.example.com" {
|
|
||||||
got = app
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
require.Nil(t, got)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "UpdateFromItem with no annotations removes existing cache entries",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
app := model.App{Config: model.AppConfig{Domain: "todelete.example.com"}}
|
|
||||||
svc.addIngressEntries(ingressKey{
|
|
||||||
namespace: "default",
|
|
||||||
name: "my-ingress",
|
|
||||||
}, []ingressEntry{
|
|
||||||
{
|
|
||||||
app: app,
|
|
||||||
name: "foo",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
item := unstructured.Unstructured{}
|
|
||||||
item.SetNamespace("default")
|
|
||||||
item.SetName("my-ingress")
|
|
||||||
|
|
||||||
svc.updateFromItem(&item)
|
|
||||||
|
|
||||||
var got *model.App
|
|
||||||
svc.getEntry(func(name string, app *model.App) bool {
|
|
||||||
if app.Config.Domain == "todelete.example.com" {
|
|
||||||
got = app
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
assert.Nil(t, got)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "ExtractPaths returns all non empty paths from a rule",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
rule := map[string]any{
|
|
||||||
"http": map[string]any{
|
|
||||||
"paths": []any{
|
|
||||||
map[string]any{"path": "/"},
|
|
||||||
map[string]any{"path": "/api"},
|
|
||||||
map[string]any{"path": ""},
|
|
||||||
map[string]any{"pathType": "Prefix"},
|
|
||||||
"not-a-map",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
paths, err := svc.extractPaths(rule)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{"/", "/api"}, paths)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "ExtractPaths returns nothing when http or paths are missing",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
paths, err := svc.extractPaths(map[string]any{})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, paths)
|
|
||||||
|
|
||||||
paths, err = svc.extractPaths(map[string]any{
|
|
||||||
"http": map[string]any{},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, paths)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "ExtractPaths errors when http is not a map",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
paths, err := svc.extractPaths(map[string]any{
|
|
||||||
"http": "invalid",
|
|
||||||
})
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Nil(t, paths)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "ExtractPaths errors when paths is not a slice",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
paths, err := svc.extractPaths(map[string]any{
|
|
||||||
"http": map[string]any{
|
|
||||||
"paths": "invalid",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Nil(t, paths)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "ExtractHosts returns hosts from all rules",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
item := unstructured.Unstructured{}
|
|
||||||
item.SetNamespace("default")
|
|
||||||
item.SetName("test-ingress")
|
|
||||||
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
|
|
||||||
map[string]any{
|
|
||||||
"host": "foo.example.com",
|
|
||||||
"http": map[string]any{
|
|
||||||
"paths": []any{
|
|
||||||
map[string]any{"path": "/"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
map[string]any{
|
|
||||||
"host": "bar.example.com",
|
|
||||||
},
|
|
||||||
map[string]any{
|
|
||||||
"host": "",
|
|
||||||
},
|
|
||||||
"not-a-map",
|
|
||||||
}, "spec", "rules"))
|
|
||||||
|
|
||||||
hosts, err := svc.extractHosts(&item)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{"foo.example.com", "bar.example.com"}, hosts)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "ExtractHosts still returns hosts when a rule has no catch all path",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
item := unstructured.Unstructured{}
|
|
||||||
item.SetNamespace("default")
|
|
||||||
item.SetName("test-ingress")
|
|
||||||
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
|
|
||||||
map[string]any{
|
|
||||||
"host": "foo.example.com",
|
|
||||||
"http": map[string]any{
|
|
||||||
"paths": []any{
|
|
||||||
map[string]any{"path": "/api"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, "spec", "rules"))
|
|
||||||
|
|
||||||
hosts, err := svc.extractHosts(&item)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{"foo.example.com"}, hosts)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "ExtractHosts still returns hosts when path extraction fails",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
item := unstructured.Unstructured{}
|
|
||||||
item.SetNamespace("default")
|
|
||||||
item.SetName("test-ingress")
|
|
||||||
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
|
|
||||||
map[string]any{
|
|
||||||
"host": "foo.example.com",
|
|
||||||
"http": "invalid",
|
|
||||||
},
|
|
||||||
}, "spec", "rules"))
|
|
||||||
|
|
||||||
hosts, err := svc.extractHosts(&item)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{"foo.example.com"}, hosts)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "ExtractHosts returns nothing when spec.rules is missing",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
item := unstructured.Unstructured{}
|
|
||||||
item.SetNamespace("default")
|
|
||||||
item.SetName("test-ingress")
|
|
||||||
|
|
||||||
hosts, err := svc.extractHosts(&item)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, hosts)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "ExtractHosts errors when spec.rules is not a slice",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
item := unstructured.Unstructured{}
|
|
||||||
item.SetNamespace("default")
|
|
||||||
item.SetName("test-ingress")
|
|
||||||
require.NoError(t, unstructured.SetNestedField(item.Object, "invalid", "spec", "rules"))
|
|
||||||
|
|
||||||
hosts, err := svc.extractHosts(&item)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Nil(t, hosts)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "UpdateFromItem registers app when its domain matches an ingress host",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
item := unstructured.Unstructured{}
|
|
||||||
item.SetNamespace("default")
|
|
||||||
item.SetName("test-ingress")
|
|
||||||
item.SetAnnotations(map[string]string{
|
|
||||||
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
|
|
||||||
})
|
|
||||||
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
|
|
||||||
map[string]any{
|
|
||||||
"host": "myapp.example.com",
|
|
||||||
},
|
|
||||||
}, "spec", "rules"))
|
|
||||||
|
|
||||||
svc.updateFromItem(&item)
|
|
||||||
|
|
||||||
var got *model.App
|
|
||||||
svc.getEntry(func(name string, app *model.App) bool {
|
|
||||||
if name == "myapp" {
|
|
||||||
got = app
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
require.NotNil(t, got)
|
|
||||||
assert.Equal(t, "myapp.example.com", got.Config.Domain)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "UpdateFromItem registers app when its name matches an ingress host prefix",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
item := unstructured.Unstructured{}
|
|
||||||
item.SetNamespace("default")
|
|
||||||
item.SetName("test-ingress")
|
|
||||||
item.SetAnnotations(map[string]string{
|
|
||||||
"tinyauth.apps.myapp.users.allow": "alice",
|
|
||||||
})
|
|
||||||
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
|
|
||||||
map[string]any{
|
|
||||||
"host": "MyApp.example.com",
|
|
||||||
},
|
|
||||||
}, "spec", "rules"))
|
|
||||||
|
|
||||||
svc.updateFromItem(&item)
|
|
||||||
|
|
||||||
var got *model.App
|
|
||||||
svc.getEntry(func(name string, app *model.App) bool {
|
|
||||||
if name == "myapp" {
|
|
||||||
got = app
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
require.NotNil(t, got)
|
|
||||||
assert.Equal(t, "alice", got.Users.Allow)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "UpdateFromItem skips apps that match neither host nor name",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
item := unstructured.Unstructured{}
|
|
||||||
item.SetNamespace("default")
|
|
||||||
item.SetName("test-ingress")
|
|
||||||
item.SetAnnotations(map[string]string{
|
|
||||||
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
|
|
||||||
})
|
|
||||||
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
|
|
||||||
map[string]any{
|
|
||||||
"host": "other.example.com",
|
|
||||||
},
|
|
||||||
}, "spec", "rules"))
|
|
||||||
|
|
||||||
svc.updateFromItem(&item)
|
|
||||||
|
|
||||||
var got *model.App
|
|
||||||
svc.getEntry(func(name string, app *model.App) bool {
|
|
||||||
got = app
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
assert.Nil(t, got)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "UpdateFromItem falls back to app name when the domain is invalid",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
item := unstructured.Unstructured{}
|
|
||||||
item.SetNamespace("default")
|
|
||||||
item.SetName("test-ingress")
|
|
||||||
item.SetAnnotations(map[string]string{
|
|
||||||
"tinyauth.apps.myapp.config.domain": "not a domain",
|
|
||||||
})
|
|
||||||
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
|
|
||||||
map[string]any{
|
|
||||||
"host": "myapp.example.com",
|
|
||||||
},
|
|
||||||
}, "spec", "rules"))
|
|
||||||
|
|
||||||
svc.updateFromItem(&item)
|
|
||||||
|
|
||||||
var got *model.App
|
|
||||||
svc.getEntry(func(name string, app *model.App) bool {
|
|
||||||
if name == "myapp" {
|
|
||||||
got = app
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
require.NotNil(t, got)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "UpdateFromItem removes entries when host extraction fails",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
key := ingressKey{
|
|
||||||
namespace: "default",
|
|
||||||
name: "test-ingress",
|
|
||||||
}
|
|
||||||
svc.addIngressEntries(key, []ingressEntry{
|
|
||||||
{
|
|
||||||
app: model.App{Config: model.AppConfig{Domain: "stale.example.com"}},
|
|
||||||
name: "foo",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
item := unstructured.Unstructured{}
|
|
||||||
item.SetNamespace(key.namespace)
|
|
||||||
item.SetName(key.name)
|
|
||||||
item.SetAnnotations(map[string]string{
|
|
||||||
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
|
|
||||||
})
|
|
||||||
require.NoError(t, unstructured.SetNestedField(item.Object, "invalid", "spec", "rules"))
|
|
||||||
|
|
||||||
svc.updateFromItem(&item)
|
|
||||||
|
|
||||||
var got *model.App
|
|
||||||
svc.getEntry(func(name string, app *model.App) bool {
|
|
||||||
got = app
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
assert.Nil(t, got)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "UpdateFromItem removes entries when annotations are not decodable",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
key := ingressKey{
|
|
||||||
namespace: "default",
|
|
||||||
name: "test-ingress",
|
|
||||||
}
|
|
||||||
svc.addIngressEntries(key, []ingressEntry{
|
|
||||||
{
|
|
||||||
app: model.App{Config: model.AppConfig{Domain: "stale.example.com"}},
|
|
||||||
name: "foo",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
item := unstructured.Unstructured{}
|
|
||||||
item.SetNamespace(key.namespace)
|
|
||||||
item.SetName(key.name)
|
|
||||||
item.SetAnnotations(map[string]string{
|
|
||||||
"tinyauth.apps.myapp.config.oauthWhitelist": "[",
|
|
||||||
})
|
|
||||||
|
|
||||||
svc.updateFromItem(&item)
|
|
||||||
|
|
||||||
var got *model.App
|
|
||||||
svc.getEntry(func(name string, app *model.App) bool {
|
|
||||||
got = app
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
assert.Nil(t, got)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.description, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
svc := &KubernetesService{
|
item, err := new(typedItem).fromUnstructured(test.resource, &test.item)
|
||||||
ingressEntries: make(map[ingressKey][]ingressEntry),
|
require.NoError(t, err)
|
||||||
log: log,
|
assert.Equal(t, test.resource, item.typ)
|
||||||
}
|
test.assert(t, item)
|
||||||
test.run(t, svc)
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKubernetesServiceLookup(t *testing.T) {
|
||||||
|
log := logger.NewLogger().WithTestConfig()
|
||||||
|
log.Init()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
connected bool
|
||||||
|
domain string
|
||||||
|
wantApp bool
|
||||||
|
}{
|
||||||
|
{"Returns a matching app when connected", true, "app.example.com", true},
|
||||||
|
{"Skips the cache before the service is connected", false, "app.example.com", false},
|
||||||
|
{"Skips an invalid domain", true, "app.example.com\xC3\xA9", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
service := newKubernetesServiceForTest(log)
|
||||||
|
service.connected = test.connected
|
||||||
|
service.addResourceEntries(resourceKey{typ: ResourceTypeIngress, namespace: "default", name: "route"}, []string{"app.example.com"}, []resourceEntry{{
|
||||||
|
name: "app",
|
||||||
|
app: model.App{Config: model.AppConfig{Domain: "app.example.com"}},
|
||||||
|
}})
|
||||||
|
|
||||||
|
var app *model.App
|
||||||
|
err := service.Lookup(test.domain, func(_ string, candidate *model.App) bool {
|
||||||
|
app = candidate
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, test.wantApp, app != nil)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKubernetesServiceKeepsResourceTypesSeparate(t *testing.T) {
|
||||||
|
log := logger.NewLogger().WithTestConfig()
|
||||||
|
log.Init()
|
||||||
|
service := newKubernetesServiceForTest(log)
|
||||||
|
|
||||||
|
resources := []struct {
|
||||||
|
resource ResourceType
|
||||||
|
item *typedItem
|
||||||
|
domain string
|
||||||
|
}{
|
||||||
|
{ResourceTypeIngress, testIngress("shared", map[string]string{"tinyauth.apps.ingress.config.domain": "ingress.example.com"}, "ingress.example.com"), "ingress.example.com"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, resource := range resources {
|
||||||
|
service.updateFromItem(watchedResourceForTest(t, resource.resource), resource.item)
|
||||||
|
}
|
||||||
|
for _, resource := range resources {
|
||||||
|
assert.NotNil(t, lookupApp(service, resource.domain))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKubernetesHostMatching(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
host string
|
||||||
|
domain string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"Exact host", "app.example.com", "app.example.com", true},
|
||||||
|
{"Case insensitive exact host", "App.Example.com", "app.example.com", true},
|
||||||
|
{"Wildcard host", "*.example.com", "deep.app.example.com", true},
|
||||||
|
{"Wildcard does not match its apex", "*.example.com", "example.com", false},
|
||||||
|
{"Different host", "app.example.com", "other.example.com", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, test.want, hostMatchesHostname(test.host, test.domain))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -882,7 +883,7 @@ func (service *OIDCService) ValidatePKCE(codeChallenge string, codeVerifier stri
|
|||||||
if codeChallenge == "" {
|
if codeChallenge == "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return codeChallenge == service.hashAndEncodePKCE(codeVerifier)
|
return subtle.ConstantTimeCompare([]byte(codeChallenge), []byte(service.hashAndEncodePKCE(codeVerifier))) == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *OIDCService) hashAndEncodePKCE(codeVerifier string) string {
|
func (service *OIDCService) hashAndEncodePKCE(codeVerifier string) string {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func CreateTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
|
|||||||
UI: model.UIConfig{
|
UI: model.UIConfig{
|
||||||
Title: "Tinyauth Test",
|
Title: "Tinyauth Test",
|
||||||
ForgotPasswordMessage: "foo",
|
ForgotPasswordMessage: "foo",
|
||||||
BackgroundImage: "/background.jpg",
|
BackgroundImage: "/background.webp",
|
||||||
WarningsEnabled: true,
|
WarningsEnabled: true,
|
||||||
},
|
},
|
||||||
OAuth: model.OAuthConfig{
|
OAuth: model.OAuthConfig{
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package v1alpha1
|
||||||
|
|
||||||
|
import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
|
||||||
|
//go:generate controller-gen object paths=$GOFILE
|
||||||
|
|
||||||
|
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
|
||||||
|
|
||||||
|
// Application is a set of access control rules that can be applied to a
|
||||||
|
// specific domain. It is an alternative to environment variable or config-based
|
||||||
|
// access controls.
|
||||||
|
type Application struct {
|
||||||
|
metav1.TypeMeta `json:",inline"`
|
||||||
|
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||||
|
|
||||||
|
Spec ApplicationSpec `json:"spec,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// +k8s:deepcopy-gen=true
|
||||||
|
|
||||||
|
// ApplicationSpec describes the application to which this rule applies to
|
||||||
|
type ApplicationSpec struct {
|
||||||
|
Config AppConfig `json:"config,omitempty"`
|
||||||
|
Users AppUsers `json:"users,omitempty"`
|
||||||
|
OAuth AppOAuth `json:"oauth,omitempty"`
|
||||||
|
IP AppIP `json:"ip,omitempty"`
|
||||||
|
Response AppResponse `json:"response,omitempty"`
|
||||||
|
Path AppPath `json:"path,omitempty"`
|
||||||
|
LDAP AppLDAP `json:"ldap,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// +k8s:deepcopy-gen=true
|
||||||
|
|
||||||
|
// AppConfig specifies configuration for the application
|
||||||
|
type AppConfig struct {
|
||||||
|
// +required
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
// +required
|
||||||
|
Domain string `json:"domain,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// +k8s:deepcopy-gen=true
|
||||||
|
|
||||||
|
// AppUsers specifies user access control rules
|
||||||
|
type AppUsers struct {
|
||||||
|
Allow string `json:"allow,omitempty"`
|
||||||
|
Block string `json:"block,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// +k8s:deepcopy-gen=true
|
||||||
|
|
||||||
|
// AppOAuth specifies OAuth access control rules
|
||||||
|
type AppOAuth struct {
|
||||||
|
Whitelist string `json:"whitelist,omitempty"`
|
||||||
|
Groups string `json:"groups,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// +k8s:deepcopy-gen=true
|
||||||
|
|
||||||
|
// AppLDAP specifies LDAP access control rules
|
||||||
|
type AppLDAP struct {
|
||||||
|
Groups string `json:"groups,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// +k8s:deepcopy-gen=true
|
||||||
|
|
||||||
|
// AppIP specifies IP access control rules
|
||||||
|
type AppIP struct {
|
||||||
|
Allow []string `json:"allow,omitempty"`
|
||||||
|
Block []string `json:"block,omitempty"`
|
||||||
|
Bypass []string `json:"bypass,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// +k8s:deepcopy-gen=true
|
||||||
|
|
||||||
|
// AppResponse specifies response headers and basic auth credentials
|
||||||
|
type AppResponse struct {
|
||||||
|
Headers []string `json:"headers,omitempty"`
|
||||||
|
BasicAuth AppBasicAuth `json:"basicAuth,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// +k8s:deepcopy-gen=true
|
||||||
|
|
||||||
|
// AppBasicAuth specifies basic auth credentials
|
||||||
|
type AppBasicAuth struct {
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
PasswordFile string `json:"passwordFile,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// +k8s:deepcopy-gen=true
|
||||||
|
|
||||||
|
// AppPath specifies path-based access control rules
|
||||||
|
type AppPath struct {
|
||||||
|
Allow string `json:"allow,omitempty"`
|
||||||
|
Block string `json:"block,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
apiVersion: apiextensions.k8s.io/v1
|
||||||
|
kind: CustomResourceDefinition
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
controller-gen.kubebuilder.io/version: v0.22.0
|
||||||
|
name: applications.tinyauth.app
|
||||||
|
spec:
|
||||||
|
group: tinyauth.app
|
||||||
|
names:
|
||||||
|
kind: Application
|
||||||
|
listKind: ApplicationList
|
||||||
|
plural: applications
|
||||||
|
singular: application
|
||||||
|
scope: Namespaced
|
||||||
|
versions:
|
||||||
|
- name: v1alpha1
|
||||||
|
schema:
|
||||||
|
openAPIV3Schema:
|
||||||
|
description: |-
|
||||||
|
Application is a set of access control rules that can be applied to a
|
||||||
|
specific domain. It is an alternative to environment variable or config-based
|
||||||
|
access controls.
|
||||||
|
properties:
|
||||||
|
apiVersion:
|
||||||
|
description: |-
|
||||||
|
APIVersion defines the versioned schema of this representation of an object.
|
||||||
|
Servers should convert recognized schemas to the latest internal value, and
|
||||||
|
may reject unrecognized values.
|
||||||
|
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||||
|
type: string
|
||||||
|
kind:
|
||||||
|
description: |-
|
||||||
|
Kind is a string value representing the REST resource this object represents.
|
||||||
|
Servers may infer this from the endpoint the client submits requests to.
|
||||||
|
Cannot be updated.
|
||||||
|
In CamelCase.
|
||||||
|
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||||
|
type: string
|
||||||
|
metadata:
|
||||||
|
type: object
|
||||||
|
spec:
|
||||||
|
description: ApplicationSpec describes the application to which this rule
|
||||||
|
applies to
|
||||||
|
properties:
|
||||||
|
config:
|
||||||
|
description: AppConfig specifies configuration for the application
|
||||||
|
properties:
|
||||||
|
domain:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- domain
|
||||||
|
- name
|
||||||
|
type: object
|
||||||
|
ip:
|
||||||
|
description: AppIP specifies IP access control rules
|
||||||
|
properties:
|
||||||
|
allow:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
block:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
bypass:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
ldap:
|
||||||
|
description: AppLDAP specifies LDAP access control rules
|
||||||
|
properties:
|
||||||
|
groups:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
oauth:
|
||||||
|
description: AppOAuth specifies OAuth access control rules
|
||||||
|
properties:
|
||||||
|
groups:
|
||||||
|
type: string
|
||||||
|
whitelist:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
path:
|
||||||
|
description: AppPath specifies path-based access control rules
|
||||||
|
properties:
|
||||||
|
allow:
|
||||||
|
type: string
|
||||||
|
block:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
response:
|
||||||
|
description: AppResponse specifies response headers and basic auth
|
||||||
|
credentials
|
||||||
|
properties:
|
||||||
|
basicAuth:
|
||||||
|
description: AppBasicAuth specifies basic auth credentials
|
||||||
|
properties:
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
passwordFile:
|
||||||
|
type: string
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
headers:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
users:
|
||||||
|
description: AppUsers specifies user access control rules
|
||||||
|
properties:
|
||||||
|
allow:
|
||||||
|
type: string
|
||||||
|
block:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
served: true
|
||||||
|
storage: true
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package v1alpha1
|
||||||
|
|
||||||
|
//+groupName=tinyauth.app
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package v1alpha1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ToInternalApp converts the ApplicationSpec to the internal App structure
|
||||||
|
func (s *ApplicationSpec) ToInternalApp() model.App {
|
||||||
|
return model.App{
|
||||||
|
Config: model.AppConfig{
|
||||||
|
Domain: s.Config.Domain,
|
||||||
|
},
|
||||||
|
Users: model.AppUsers{
|
||||||
|
Allow: s.Users.Allow,
|
||||||
|
Block: s.Users.Block,
|
||||||
|
},
|
||||||
|
OAuth: model.AppOAuth{
|
||||||
|
Whitelist: s.OAuth.Whitelist,
|
||||||
|
Groups: s.OAuth.Groups,
|
||||||
|
},
|
||||||
|
IP: model.AppIP{
|
||||||
|
Allow: s.IP.Allow,
|
||||||
|
Block: s.IP.Block,
|
||||||
|
Bypass: s.IP.Bypass,
|
||||||
|
},
|
||||||
|
Response: model.AppResponse{
|
||||||
|
Headers: s.Response.Headers,
|
||||||
|
BasicAuth: model.AppBasicAuth{
|
||||||
|
Username: s.Response.BasicAuth.Username,
|
||||||
|
Password: s.Response.BasicAuth.Password,
|
||||||
|
PasswordFile: s.Response.BasicAuth.PasswordFile,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Path: model.AppPath{
|
||||||
|
Allow: s.Path.Allow,
|
||||||
|
Block: s.Path.Block,
|
||||||
|
},
|
||||||
|
LDAP: model.AppLDAP{
|
||||||
|
Groups: s.LDAP.Groups,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package v1alpha1
|
||||||
|
|
||||||
|
import (
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
const GroupName = "tinyauth.app"
|
||||||
|
const GroupVersion = "v1alpha1"
|
||||||
|
|
||||||
|
var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: GroupVersion}
|
||||||
|
|
||||||
|
var (
|
||||||
|
SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes)
|
||||||
|
AddToScheme = SchemeBuilder.AddToScheme
|
||||||
|
)
|
||||||
|
|
||||||
|
func addKnownTypes(scheme *runtime.Scheme) error {
|
||||||
|
scheme.AddKnownTypes(SchemeGroupVersion,
|
||||||
|
&Application{},
|
||||||
|
&ApplicationSet{},
|
||||||
|
)
|
||||||
|
|
||||||
|
metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
//go:build !ignore_autogenerated
|
||||||
|
|
||||||
|
// Code generated by controller-gen. DO NOT EDIT.
|
||||||
|
|
||||||
|
package v1alpha1
|
||||||
|
|
||||||
|
import (
|
||||||
|
runtime "k8s.io/apimachinery/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *AppBasicAuth) DeepCopyInto(out *AppBasicAuth) {
|
||||||
|
*out = *in
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppBasicAuth.
|
||||||
|
func (in *AppBasicAuth) DeepCopy() *AppBasicAuth {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(AppBasicAuth)
|
||||||
|
in.DeepCopyInto(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *AppConfig) DeepCopyInto(out *AppConfig) {
|
||||||
|
*out = *in
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppConfig.
|
||||||
|
func (in *AppConfig) DeepCopy() *AppConfig {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(AppConfig)
|
||||||
|
in.DeepCopyInto(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *AppIP) DeepCopyInto(out *AppIP) {
|
||||||
|
*out = *in
|
||||||
|
if in.Allow != nil {
|
||||||
|
in, out := &in.Allow, &out.Allow
|
||||||
|
*out = make([]string, len(*in))
|
||||||
|
copy(*out, *in)
|
||||||
|
}
|
||||||
|
if in.Block != nil {
|
||||||
|
in, out := &in.Block, &out.Block
|
||||||
|
*out = make([]string, len(*in))
|
||||||
|
copy(*out, *in)
|
||||||
|
}
|
||||||
|
if in.Bypass != nil {
|
||||||
|
in, out := &in.Bypass, &out.Bypass
|
||||||
|
*out = make([]string, len(*in))
|
||||||
|
copy(*out, *in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppIP.
|
||||||
|
func (in *AppIP) DeepCopy() *AppIP {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(AppIP)
|
||||||
|
in.DeepCopyInto(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *AppLDAP) DeepCopyInto(out *AppLDAP) {
|
||||||
|
*out = *in
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppLDAP.
|
||||||
|
func (in *AppLDAP) DeepCopy() *AppLDAP {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(AppLDAP)
|
||||||
|
in.DeepCopyInto(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *AppOAuth) DeepCopyInto(out *AppOAuth) {
|
||||||
|
*out = *in
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppOAuth.
|
||||||
|
func (in *AppOAuth) DeepCopy() *AppOAuth {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(AppOAuth)
|
||||||
|
in.DeepCopyInto(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *AppPath) DeepCopyInto(out *AppPath) {
|
||||||
|
*out = *in
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppPath.
|
||||||
|
func (in *AppPath) DeepCopy() *AppPath {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(AppPath)
|
||||||
|
in.DeepCopyInto(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *AppResponse) DeepCopyInto(out *AppResponse) {
|
||||||
|
*out = *in
|
||||||
|
if in.Headers != nil {
|
||||||
|
in, out := &in.Headers, &out.Headers
|
||||||
|
*out = make([]string, len(*in))
|
||||||
|
copy(*out, *in)
|
||||||
|
}
|
||||||
|
out.BasicAuth = in.BasicAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppResponse.
|
||||||
|
func (in *AppResponse) DeepCopy() *AppResponse {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(AppResponse)
|
||||||
|
in.DeepCopyInto(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *AppUsers) DeepCopyInto(out *AppUsers) {
|
||||||
|
*out = *in
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppUsers.
|
||||||
|
func (in *AppUsers) DeepCopy() *AppUsers {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(AppUsers)
|
||||||
|
in.DeepCopyInto(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *Application) DeepCopyInto(out *Application) {
|
||||||
|
*out = *in
|
||||||
|
out.TypeMeta = in.TypeMeta
|
||||||
|
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||||
|
in.Spec.DeepCopyInto(&out.Spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Application.
|
||||||
|
func (in *Application) DeepCopy() *Application {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(Application)
|
||||||
|
in.DeepCopyInto(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||||
|
func (in *Application) DeepCopyObject() runtime.Object {
|
||||||
|
if c := in.DeepCopy(); c != nil {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *ApplicationSpec) DeepCopyInto(out *ApplicationSpec) {
|
||||||
|
*out = *in
|
||||||
|
out.Config = in.Config
|
||||||
|
out.Users = in.Users
|
||||||
|
out.OAuth = in.OAuth
|
||||||
|
in.IP.DeepCopyInto(&out.IP)
|
||||||
|
in.Response.DeepCopyInto(&out.Response)
|
||||||
|
out.Path = in.Path
|
||||||
|
out.LDAP = in.LDAP
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplicationSpec.
|
||||||
|
func (in *ApplicationSpec) DeepCopy() *ApplicationSpec {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(ApplicationSpec)
|
||||||
|
in.DeepCopyInto(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user