Compare commits
230 commits
5fcf76066f
...
ea1ad23bff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea1ad23bff |
||
|
|
7ffc5e0212 |
||
|
|
acba9444f4 |
||
|
|
f7e3671801 |
||
|
|
a1b2e36a5d |
||
|
|
44e96942b3 |
||
|
|
f2efa760ff |
||
|
|
256df9042b |
||
|
|
6d7091fb5c |
||
|
|
0d1f88a368 |
||
|
|
2ae601717d |
||
|
|
c7c8b463b4 |
||
|
|
282f839211 |
||
|
|
b2eb846b69 |
||
|
|
62cf925dcf |
||
|
|
e699f84c4d | ||
|
|
c1189dadc5 | ||
|
|
76bc080a6d |
||
|
|
7f989f77ac |
||
|
|
b916f768fe |
||
|
|
e4509c5714 |
||
|
|
ddb6893a64 |
||
|
|
248751ba1d | ||
|
|
b4b74ed53a | ||
|
|
76903cd67f | ||
|
|
e4f2eac703 | ||
|
|
3aa45007a7 | ||
|
|
f452892c88 |
||
|
|
a0fece8a0e |
||
|
|
e3d493209b |
||
|
|
2e8b63553d | ||
|
|
fb8f4b95b7 |
||
|
|
83e107c713 |
||
|
|
e97642a790 |
||
|
|
426d8684bf |
||
|
|
5e7409a4f0 |
||
|
|
c225a4cd48 |
||
|
|
24df9e1ce6 |
||
|
|
eab1fd3722 | ||
|
|
93bd041693 | ||
|
|
665ebe993c | ||
|
|
4086130371 | ||
|
|
29aacf5238 | ||
|
|
497e6a8422 | ||
|
|
af8572add9 | ||
|
|
d6aea96400 | ||
|
|
17e26ff1a6 | ||
|
|
f5f223348d | ||
|
|
e4f90fd7ea | ||
|
|
96dff20760 | ||
|
|
d639f7f6de | ||
|
|
5b35ec2ea2 | ||
|
|
bc78b95265 |
||
|
|
97f22eccbb |
||
|
|
a4fe86e38a |
||
|
|
4bc1e10ecb |
||
|
|
5b840d73bb |
||
|
|
afa9acfb1e |
||
|
|
7b7f65da39 |
||
|
|
806da59f47 |
||
|
|
9a009a4ea3 |
||
|
|
083d890053 |
||
|
|
e693a8aeb8 |
||
|
|
831b46d7b5 |
||
|
|
8dd3022b94 |
||
|
|
b278eb7110 |
||
|
|
7a66163216 |
||
|
|
dda2043401 |
||
|
|
08d6183c9b |
||
|
|
eea0b86d6d |
||
|
|
58c04fd196 |
||
|
|
09de6f6b5f | ||
|
|
d0bbd2b539 | ||
|
|
134595a6b7 | ||
|
|
4ff46f1650 |
||
|
|
4779201d4c | ||
|
|
3a8643d83c |
||
|
|
806a49b822 |
||
|
|
95d74825ee |
||
|
|
e4960909ed |
||
|
|
6cb36aaf13 |
||
|
|
cb06e93650 |
||
|
|
e3a2f7a514 |
||
|
|
01b1e817d8 |
||
|
|
c3a5195575 |
||
|
|
99765c7bd5 |
||
|
|
8929f389f4 |
||
|
|
4141d91f1b | ||
|
|
90272c84d2 | ||
|
|
3006a8e58c | ||
|
|
52a9dbd45d | ||
|
|
b2fb55d2c1 | ||
|
|
a1c16d22d8 | ||
|
|
0e9504ee4d | ||
|
|
e8cad6fc20 | ||
|
|
bc261f7739 | ||
|
|
0b8983a86b | ||
|
|
5a61da3c53 | ||
|
|
800fe6244c | ||
|
|
9e1fec812c | ||
|
|
61632f9c97 | ||
|
|
f5e44129d8 |
||
|
|
3eaca924da |
||
|
|
da1c706334 |
||
|
|
3b726dfb1e |
||
|
|
d51e7f7e40 |
||
|
|
2551e0c291 |
||
|
|
2efd5c31ab |
||
|
|
1eacb8ff36 |
||
|
|
612446c3c9 |
||
|
|
e121e16ad9 |
||
|
|
23616b41be |
||
|
|
1778ba49b2 | ||
|
|
b6f2bd4703 | ||
|
|
5fd67224f6 | ||
|
|
c9d21dde0c | ||
|
|
de2c5aa068 | ||
|
|
ad01cecae6 | ||
|
|
75ef14c75b | ||
|
|
03a5a0eddb | ||
|
|
66befd35eb | ||
|
|
3cbad16c30 | ||
|
|
3bba7c5956 | ||
|
|
0daa84c583 | ||
|
|
92358a52c0 | ||
|
|
faf17e9e86 | ||
|
|
ef6efe94b4 |
||
|
|
819d7ea23e |
||
|
|
61ff192cfd |
||
|
|
ceb1b07ce2 |
||
|
|
90188d4358 |
||
|
|
35aa0ab4e7 |
||
|
|
14dd76db8b |
||
|
|
fb26dfad65 |
||
|
|
bedc5adb75 |
||
|
|
800b1f1520 |
||
|
|
a4571a80ae |
||
|
|
a0a612618e |
||
|
|
db94728a5b |
||
|
|
04352a670a |
||
|
|
fe6e3b013e |
||
|
|
a947a74194 |
||
|
|
06055ff62b |
||
|
|
45cb1562e5 |
||
|
|
2f89a16852 | ||
|
|
86956b8cac | ||
|
|
84fb3add33 | ||
|
|
56ee68d9f3 |
||
|
|
e81fd3bb31 |
||
|
|
938ca29777 |
||
|
|
122902968f |
||
|
|
b55c30065f |
||
|
|
92ac2dbac2 |
||
|
|
d3e6decef9 |
||
|
|
579cd9d338 |
||
|
|
9e2a58dd46 | ||
|
|
64722617c1 |
||
|
|
5845ddbdda |
||
|
|
bf9ce0df9b | ||
|
|
8aee2ec3a1 |
||
|
|
3292eafe4a |
||
|
|
9ad31b2c81 |
||
|
|
374ed79a18 |
||
|
|
3d5f73e344 |
||
|
|
6761428a96 |
||
|
|
0a9b463eaa | ||
|
|
c219256fff | ||
|
|
7e48803dc5 | ||
|
|
d496b8a414 | ||
|
|
4825129560 | ||
|
|
adc54b2582 | ||
|
|
863567c9b6 | ||
|
|
102555023b | ||
|
|
f1a9eef531 | ||
|
|
5f007a5b0f | ||
|
|
9455141262 | ||
|
|
37e1379c88 | ||
|
|
55d597e519 | ||
|
|
da5ee5c951 | ||
|
|
b0bd9279fc |
||
|
|
90456339ca |
||
|
|
a653c8bad7 |
||
|
|
c4fa6cf458 |
||
|
|
268fc7b923 |
||
|
|
02604f5290 |
||
|
|
1dad7e86a0 |
||
|
|
838e3efbca |
||
|
|
3e353717f5 |
||
|
|
0a4b74b91a |
||
|
|
e69fbf3ccf |
||
|
|
0b2349d6bf |
||
|
|
3a8f04cf14 |
||
|
|
e941cf956f |
||
|
|
29f7bcf6f5 | ||
|
|
1cf1e0dc57 | ||
|
|
175283805e | ||
|
|
063c0405e8 | ||
|
|
947cb77753 | ||
|
|
28b3b305ea |
||
|
|
df85f13aea |
||
|
|
042e2c1390 |
||
|
|
e6314bee35 |
||
|
|
4292d3262e |
||
|
|
35d070ad29 |
||
|
|
cd79e77576 |
||
|
|
1f1c20d637 |
||
|
|
e87b3b1b54 | ||
|
|
a6f7b65625 | ||
|
|
722fa47132 | ||
|
|
f83e290b4c | ||
|
|
11b4047283 | ||
|
|
69b2032a86 | ||
|
|
636298569f | ||
|
|
ed8a282d35 | ||
|
|
3bd5e850e0 | ||
|
|
070f1f9159 | ||
|
|
195644cca5 | ||
|
|
8092c86ecd | ||
|
|
28f33702da | ||
|
|
570632b8be | ||
|
|
f2881e1b31 |
||
|
|
dad35e37ef |
||
|
|
39afabd60e |
||
|
|
dc7e14a34b |
||
|
|
1dca71a779 |
||
|
|
e9494efa8e | ||
|
|
8159a0f13d |
||
|
|
ee9101e738 | ||
|
|
b670e6e3dc | ||
|
|
5e5754fa62 |
198 changed files with 9416 additions and 1711 deletions
2
.github/workflows/cicd.yml
vendored
2
.github/workflows/cicd.yml
vendored
|
|
@ -28,7 +28,7 @@ jobs:
|
|||
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: 1.24
|
||||
|
||||
|
|
|
|||
2
.github/workflows/linting.yml
vendored
2
.github/workflows/linting.yml
vendored
|
|
@ -21,7 +21,7 @@ jobs:
|
|||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
|
|
|
|||
2
.github/workflows/stale-bot.yml
vendored
2
.github/workflows/stale-bot.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
|||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
days-before-stale: 14
|
||||
days-before-close: 14
|
||||
|
|
|
|||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -20,19 +20,28 @@ _Pangolin tunnels your services to the internet so you can access anything from
|
|||
Website
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://docs.digpangolin.com/self-host/quick-install">
|
||||
Install Guide
|
||||
<a href="https://docs.digpangolin.com/self-host/quick-install-managed">
|
||||
Quick Install Guide
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="mailto:numbat@fossorial.io">
|
||||
<a href="mailto:contact@fossorial.io">
|
||||
Contact Us
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://digpangolin.com/slack">
|
||||
Slack
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://discord.gg/HCJR8Xhme4">
|
||||
Discord
|
||||
</a>
|
||||
</h5>
|
||||
|
||||
[](https://digpangolin.com/slack)
|
||||
[](https://hub.docker.com/r/fosrl/pangolin)
|
||||

|
||||
[](https://discord.gg/HCJR8Xhme4)
|
||||
[](https://www.youtube.com/@fossorial-app)
|
||||
[](https://www.youtube.com/@fossorial-app)
|
||||
|
||||
</div>
|
||||
|
||||
|
|
|
|||
72
blueprint.py
Normal file
72
blueprint.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import requests
|
||||
import yaml
|
||||
import json
|
||||
import base64
|
||||
|
||||
# The file path for the YAML file to be read
|
||||
# You can change this to the path of your YAML file
|
||||
YAML_FILE_PATH = 'blueprint.yaml'
|
||||
|
||||
# The API endpoint and headers from the curl request
|
||||
API_URL = 'http://localhost:3004/v1/org/test/blueprint'
|
||||
HEADERS = {
|
||||
'accept': '*/*',
|
||||
'Authorization': 'Bearer v7ix7xha1bmq2on.tzsden374mtmkeczm3tx44uzxsljnrst7nmg7ccr',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
def convert_and_send(file_path, url, headers):
|
||||
"""
|
||||
Reads a YAML file, converts its content to a JSON payload,
|
||||
and sends it via a PUT request to a specified URL.
|
||||
"""
|
||||
try:
|
||||
# Read the YAML file content
|
||||
with open(file_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
|
||||
# Parse the YAML string to a Python dictionary
|
||||
# This will be used to ensure the YAML is valid before sending
|
||||
parsed_yaml = yaml.safe_load(yaml_content)
|
||||
|
||||
# convert the parsed YAML to a JSON string
|
||||
json_payload = json.dumps(parsed_yaml)
|
||||
print("Converted JSON payload:")
|
||||
print(json_payload)
|
||||
|
||||
# Encode the JSON string to Base64
|
||||
encoded_json = base64.b64encode(json_payload.encode('utf-8')).decode('utf-8')
|
||||
|
||||
# Create the final payload with the base64 encoded data
|
||||
final_payload = {
|
||||
"blueprint": encoded_json
|
||||
}
|
||||
|
||||
print("Sending the following Base64 encoded JSON payload:")
|
||||
print(final_payload)
|
||||
print("-" * 20)
|
||||
|
||||
# Make the PUT request with the base64 encoded payload
|
||||
response = requests.put(url, headers=headers, json=final_payload)
|
||||
|
||||
# Print the API response for debugging
|
||||
print(f"API Response Status Code: {response.status_code}")
|
||||
print("API Response Content:")
|
||||
print(response.text)
|
||||
|
||||
# Raise an exception for bad status codes (4xx or 5xx)
|
||||
response.raise_for_status()
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"Error: The file '{file_path}' was not found.")
|
||||
except yaml.YAMLError as e:
|
||||
print(f"Error parsing YAML file: {e}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"An error occurred during the API request: {e}")
|
||||
except Exception as e:
|
||||
print(f"An unexpected error occurred: {e}")
|
||||
|
||||
# Run the function
|
||||
if __name__ == "__main__":
|
||||
convert_and_send(YAML_FILE_PATH, API_URL, HEADERS)
|
||||
|
||||
69
blueprint.yaml
Normal file
69
blueprint.yaml
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
client-resources:
|
||||
client-resource-nice-id-uno:
|
||||
name: this is my resource
|
||||
protocol: tcp
|
||||
proxy-port: 3001
|
||||
hostname: localhost
|
||||
internal-port: 3000
|
||||
site: lively-yosemite-toad
|
||||
client-resource-nice-id-duce:
|
||||
name: this is my resource
|
||||
protocol: udp
|
||||
proxy-port: 3000
|
||||
hostname: localhost
|
||||
internal-port: 3000
|
||||
site: lively-yosemite-toad
|
||||
|
||||
proxy-resources:
|
||||
resource-nice-id-uno:
|
||||
name: this is my resource
|
||||
protocol: http
|
||||
full-domain: duce.test.example.com
|
||||
host-header: example.com
|
||||
tls-server-name: example.com
|
||||
# auth:
|
||||
# pincode: 123456
|
||||
# password: sadfasdfadsf
|
||||
# sso-enabled: true
|
||||
# sso-roles:
|
||||
# - Member
|
||||
# sso-users:
|
||||
# - owen@fossorial.io
|
||||
# whitelist-users:
|
||||
# - owen@fossorial.io
|
||||
headers:
|
||||
- name: X-Example-Header
|
||||
value: example-value
|
||||
- name: X-Another-Header
|
||||
value: another-value
|
||||
rules:
|
||||
- action: allow
|
||||
match: ip
|
||||
value: 1.1.1.1
|
||||
- action: deny
|
||||
match: cidr
|
||||
value: 2.2.2.2/32
|
||||
- action: pass
|
||||
match: path
|
||||
value: /admin
|
||||
targets:
|
||||
- site: lively-yosemite-toad
|
||||
path: /path
|
||||
pathMatchType: prefix
|
||||
hostname: localhost
|
||||
method: http
|
||||
port: 8000
|
||||
- site: slim-alpine-chipmunk
|
||||
hostname: localhost
|
||||
path: /yoman
|
||||
pathMatchType: exact
|
||||
method: http
|
||||
port: 8001
|
||||
resource-nice-id-duce:
|
||||
name: this is other resource
|
||||
protocol: tcp
|
||||
proxy-port: 3000
|
||||
targets:
|
||||
- site: lively-yosemite-toad
|
||||
hostname: localhost
|
||||
port: 3000
|
||||
|
|
@ -16,8 +16,9 @@ http:
|
|||
|
||||
# Next.js router (handles everything except API and WebSocket paths)
|
||||
next-router:
|
||||
rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)"
|
||||
rule: "Host(`{{.DashboardDomain}}`)"
|
||||
service: next-service
|
||||
priority: 10
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
|
|
@ -27,15 +28,7 @@ http:
|
|||
api-router:
|
||||
rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)"
|
||||
service: api-service
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
# WebSocket router
|
||||
ws-router:
|
||||
rule: "Host(`{{.DashboardDomain}}`)"
|
||||
service: api-service
|
||||
priority: 100
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ services:
|
|||
environment:
|
||||
- NODE_ENV=development
|
||||
- ENVIRONMENT=dev
|
||||
- DB_TYPE=pg
|
||||
volumes:
|
||||
# Mount source code for hot reload
|
||||
- ./src:/app/src
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ esbuild
|
|||
bundle: true,
|
||||
outfile: argv.out,
|
||||
format: "esm",
|
||||
minify: true,
|
||||
minify: false,
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
|
|
@ -63,7 +63,7 @@ esbuild
|
|||
packagePath: getPackagePaths(),
|
||||
}),
|
||||
],
|
||||
sourcemap: "external",
|
||||
sourcemap: "inline",
|
||||
target: "node22",
|
||||
})
|
||||
.then(() => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
module installer
|
||||
|
||||
go 1.24
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
golang.org/x/term v0.34.0
|
||||
golang.org/x/term v0.35.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.35.0 // indirect
|
||||
require golang.org/x/sys v0.36.0 // indirect
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import (
|
|||
"io"
|
||||
"io/fs"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
|
@ -15,14 +17,13 @@ import (
|
|||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
"net"
|
||||
)
|
||||
|
||||
// DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD
|
||||
func loadVersions(config *Config) {
|
||||
config.PangolinVersion = "replaceme"
|
||||
config.GerbilVersion = "replaceme"
|
||||
config.BadgerVersion = "replaceme"
|
||||
config.PangolinVersion = "1.9.4"
|
||||
config.GerbilVersion = "1.2.1"
|
||||
config.BadgerVersion = "1.2.0"
|
||||
}
|
||||
|
||||
//go:embed config/*
|
||||
|
|
@ -74,7 +75,7 @@ func main() {
|
|||
if err := checkPortsAvailable(p); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
|
||||
fmt.Printf("Please close any services on ports 80/443 in order to run the installation smoothly")
|
||||
fmt.Printf("Please close any services on ports 80/443 in order to run the installation smoothly. If you already have the Pangolin stack running, shut them down before proceeding.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -126,7 +127,7 @@ func main() {
|
|||
if readBool(reader, "Would you like to install and start the containers?", true) {
|
||||
|
||||
config.InstallationContainerType = podmanOrDocker(reader)
|
||||
|
||||
|
||||
if !isDockerInstalled() && runtime.GOOS == "linux" && config.InstallationContainerType == Docker {
|
||||
if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
|
||||
installDocker()
|
||||
|
|
@ -204,8 +205,17 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
config.InstallationContainerType = podmanOrDocker(reader)
|
||||
|
||||
config.DoCrowdsecInstall = true
|
||||
installCrowdsec(config)
|
||||
err := installCrowdsec(config)
|
||||
if (err != nil) {
|
||||
fmt.Printf("Error installing CrowdSec: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("CrowdSec installed successfully!")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -322,13 +332,18 @@ func collectUserInput(reader *bufio.Reader) Config {
|
|||
|
||||
if config.HybridMode {
|
||||
alreadyHaveCreds := readBool(reader, "Do you already have credentials from the dashboard? If not, we will create them later", false)
|
||||
|
||||
|
||||
if alreadyHaveCreds {
|
||||
config.HybridId = readString(reader, "Enter your ID", "")
|
||||
config.HybridSecret = readString(reader, "Enter your secret", "")
|
||||
}
|
||||
}
|
||||
|
||||
config.DashboardDomain = readString(reader, "The public addressable IP address for this node or a domain pointing to it", "")
|
||||
// Try to get public IP as default
|
||||
publicIP := getPublicIP()
|
||||
if publicIP != "" {
|
||||
fmt.Printf("Detected public IP: %s\n", publicIP)
|
||||
}
|
||||
config.DashboardDomain = readString(reader, "The public addressable IP address for this node or a domain pointing to it", publicIP)
|
||||
config.InstallGerbil = true
|
||||
} else {
|
||||
config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
|
||||
|
|
@ -345,7 +360,7 @@ func collectUserInput(reader *bufio.Reader) Config {
|
|||
// Email configuration
|
||||
fmt.Println("\n=== Email Configuration ===")
|
||||
config.EnableEmail = readBool(reader, "Enable email functionality (SMTP)", false)
|
||||
|
||||
|
||||
if config.EnableEmail {
|
||||
config.EmailSMTPHost = readString(reader, "Enter SMTP host", "")
|
||||
config.EmailSMTPPort = readInt(reader, "Enter SMTP port (default 587)", 587)
|
||||
|
|
@ -353,7 +368,7 @@ func collectUserInput(reader *bufio.Reader) Config {
|
|||
config.EmailSMTPPass = readString(reader, "Enter SMTP password", "") // Should this be readPassword?
|
||||
config.EmailNoReply = readString(reader, "Enter no-reply email address", "")
|
||||
}
|
||||
|
||||
|
||||
// Validate required fields
|
||||
if config.BaseDomain == "" {
|
||||
fmt.Println("Error: Domain name is required")
|
||||
|
|
@ -584,6 +599,32 @@ func generateRandomSecretKey() string {
|
|||
return string(b)
|
||||
}
|
||||
|
||||
func getPublicIP() string {
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Get("https://ifconfig.io/ip")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
ip := strings.TrimSpace(string(body))
|
||||
|
||||
// Validate that it's a valid IP address
|
||||
if net.ParseIP(ip) != nil {
|
||||
return ip
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// Run external commands with stdio/stderr attached.
|
||||
func run(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
|
|
|
|||
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "An error occurred while adding user to the role.",
|
||||
"userSaved": "User saved",
|
||||
"userSavedDescription": "The user has been updated.",
|
||||
"autoProvisioned": "Auto Provisioned",
|
||||
"autoProvisionedDescription": "Allow this user to be automatically managed by identity provider",
|
||||
"accessControlsDescription": "Manage what this user can access and do in the organization",
|
||||
"accessControlsSubmit": "Save Access Controls",
|
||||
"roles": "Roles",
|
||||
|
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Invalid IP address format",
|
||||
"ipAddressErrorInvalidOctet": "Invalid IP address octet",
|
||||
"path": "Path",
|
||||
"matchPath": "Match Path",
|
||||
"ipAddressRange": "IP Range",
|
||||
"rulesErrorFetch": "Failed to fetch rules",
|
||||
"rulesErrorFetchDescription": "An error occurred while fetching rules",
|
||||
|
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Connected",
|
||||
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
|
||||
"idpErrorNotFound": "IdP not found",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Invalid Invite",
|
||||
"inviteInvalidDescription": "The invite link is invalid.",
|
||||
"inviteErrorWrongUser": "Invite is not for this user",
|
||||
|
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Professional Edition Required",
|
||||
"licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.",
|
||||
"actionGetOrg": "Get Organization",
|
||||
"updateOrgUser": "Update Org User",
|
||||
"createOrgUser": "Create Org User",
|
||||
"actionUpdateOrg": "Update Organization",
|
||||
"actionUpdateUser": "Update User",
|
||||
"actionGetUser": "Get User",
|
||||
|
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Delete Site",
|
||||
"actionGetSite": "Get Site",
|
||||
"actionListSites": "List Sites",
|
||||
"actionApplyBlueprint": "Apply Blueprint",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenDescription": "Enter the setup token from the server console.",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
|
|
@ -1059,6 +1067,7 @@
|
|||
"actionGetSiteResource": "Get Site Resource",
|
||||
"actionListSiteResources": "List Site Resources",
|
||||
"actionUpdateSiteResource": "Update Site Resource",
|
||||
"actionListInvitations": "List Invitations",
|
||||
"noneSelected": "None selected",
|
||||
"orgNotFound2": "No organizations found.",
|
||||
"searchProgress": "Search...",
|
||||
|
|
@ -1132,8 +1141,8 @@
|
|||
"sidebarLicense": "License",
|
||||
"sidebarClients": "Clients (Beta)",
|
||||
"sidebarDomains": "Domains",
|
||||
"enableDockerSocket": "Enable Docker Socket",
|
||||
"enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.",
|
||||
"enableDockerSocket": "Enable Docker Blueprint",
|
||||
"enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt.",
|
||||
"enableDockerSocketLink": "Learn More",
|
||||
"viewDockerContainers": "View Docker Containers",
|
||||
"containersIn": "Containers in {siteName}",
|
||||
|
|
@ -1233,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Update Available",
|
||||
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
||||
"domainPickerEnterDomain": "Domain",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp",
|
||||
"domainPickerPlaceholder": "myapp.example.com",
|
||||
"domainPickerDescription": "Enter the full domain of the resource to see available options.",
|
||||
"domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
|
||||
"domainPickerTabAll": "All",
|
||||
|
|
@ -1391,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protocol",
|
||||
"editInternalResourceDialogSitePort": "Site Port",
|
||||
"editInternalResourceDialogTargetConfiguration": "Target Configuration",
|
||||
"editInternalResourceDialogDestinationIP": "Destination IP",
|
||||
"editInternalResourceDialogDestinationPort": "Destination Port",
|
||||
"editInternalResourceDialogCancel": "Cancel",
|
||||
"editInternalResourceDialogSaveResource": "Save Resource",
|
||||
"editInternalResourceDialogSuccess": "Success",
|
||||
|
|
@ -1423,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Site Port",
|
||||
"createInternalResourceDialogSitePortDescription": "Use this port to access the resource on the site when connected with a client.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Target Configuration",
|
||||
"createInternalResourceDialogDestinationIP": "Destination IP",
|
||||
"createInternalResourceDialogDestinationIPDescription": "The IP address of the resource on the site's network.",
|
||||
"createInternalResourceDialogDestinationPort": "Destination Port",
|
||||
"createInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "The port on the destination IP where the resource is accessible.",
|
||||
"createInternalResourceDialogCancel": "Cancel",
|
||||
"createInternalResourceDialogCreateResource": "Create Resource",
|
||||
|
|
@ -1458,6 +1463,61 @@
|
|||
"autoLoginError": "Auto Login Error",
|
||||
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
|
||||
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.",
|
||||
"managedSelfHosted": {
|
||||
"title": "Managed Self-Hosted",
|
||||
"description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles",
|
||||
"introTitle": "Managed Self-Hosted Pangolin",
|
||||
"introDescription": "is a deployment option designed for people who want simplicity and extra reliability while still keeping their data private and self-hosted.",
|
||||
"introDetail": "With this option, you still run your own Pangolin node — your tunnels, SSL termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Simpler operations",
|
||||
"description": "No need to run your own mail server or set up complex alerting. You'll get health checks and downtime alerts out of the box."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Automatic updates",
|
||||
"description": "The cloud dashboard evolves quickly, so you get new features and bug fixes without having to manually pull new containers every time."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Less maintenance",
|
||||
"description": "No database migrations, backups, or extra infrastructure to manage. We handle that in the cloud."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Cloud failover",
|
||||
"description": "If your node goes down, your tunnels can temporarily fail over to our cloud points of presence until you bring it back online."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "High availability (PoPs)",
|
||||
"description": "You can also attach multiple nodes to your account for redundancy and better performance."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Future enhancements",
|
||||
"description": "We're planning to add more analytics, alerting, and management tools to make your deployment even more robust."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Learn more about the Managed Self-Hosted option in our",
|
||||
"documentation": "documentation"
|
||||
},
|
||||
"convertButton": "Convert This Node to Managed Self-Hosted"
|
||||
},
|
||||
"internationaldomaindetected": "International Domain Detected",
|
||||
"willbestoredas": "Will be stored as:"
|
||||
"willbestoredas": "Will be stored as:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC provider",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Custom Headers",
|
||||
"headersValidationError": "Headers must be in the format: Header-Name: value.",
|
||||
"domainPickerProvidedDomain": "Provided Domain",
|
||||
"domainPickerFreeProvidedDomain": "Free Provided Domain",
|
||||
"domainPickerVerified": "Verified",
|
||||
"domainPickerUnverified": "Unverified",
|
||||
"domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.",
|
||||
"domainPickerError": "Error",
|
||||
"domainPickerErrorLoadDomains": "Failed to load organization domains",
|
||||
"domainPickerErrorCheckAvailability": "Failed to check domain availability",
|
||||
"domainPickerInvalidSubdomain": "Invalid subdomain",
|
||||
"domainPickerInvalidSubdomainRemoved": "The input \"{sub}\" was removed because it's not valid.",
|
||||
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" could not be made valid for {domain}.",
|
||||
"domainPickerSubdomainSanitized": "Subdomain sanitized",
|
||||
"domainPickerSubdomainCorrected": "\"{sub}\" was corrected to \"{sanitized}\"",
|
||||
"resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Edit file: docker-compose.yml"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "An error occurred while adding user to the role.",
|
||||
"userSaved": "User saved",
|
||||
"userSavedDescription": "The user has been updated.",
|
||||
"autoProvisioned": "Auto Provisioned",
|
||||
"autoProvisionedDescription": "Allow this user to be automatically managed by identity provider",
|
||||
"accessControlsDescription": "Manage what this user can access and do in the organization",
|
||||
"accessControlsSubmit": "Save Access Controls",
|
||||
"roles": "Roles",
|
||||
|
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Invalid IP address format",
|
||||
"ipAddressErrorInvalidOctet": "Invalid IP address octet",
|
||||
"path": "Path",
|
||||
"matchPath": "Match Path",
|
||||
"ipAddressRange": "IP Range",
|
||||
"rulesErrorFetch": "Failed to fetch rules",
|
||||
"rulesErrorFetchDescription": "An error occurred while fetching rules",
|
||||
|
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Connected",
|
||||
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
|
||||
"idpErrorNotFound": "IdP not found",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Invalid Invite",
|
||||
"inviteInvalidDescription": "The invite link is invalid.",
|
||||
"inviteErrorWrongUser": "Invite is not for this user",
|
||||
|
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Professional Edition Required",
|
||||
"licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.",
|
||||
"actionGetOrg": "Get Organization",
|
||||
"updateOrgUser": "Update Org User",
|
||||
"createOrgUser": "Create Org User",
|
||||
"actionUpdateOrg": "Update Organization",
|
||||
"actionUpdateUser": "Update User",
|
||||
"actionGetUser": "Get User",
|
||||
|
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Delete Site",
|
||||
"actionGetSite": "Get Site",
|
||||
"actionListSites": "List Sites",
|
||||
"actionApplyBlueprint": "Apply Blueprint",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenDescription": "Enter the setup token from the server console.",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
|
|
@ -1059,6 +1067,7 @@
|
|||
"actionGetSiteResource": "Get Site Resource",
|
||||
"actionListSiteResources": "List Site Resources",
|
||||
"actionUpdateSiteResource": "Update Site Resource",
|
||||
"actionListInvitations": "List Invitations",
|
||||
"noneSelected": "None selected",
|
||||
"orgNotFound2": "No organizations found.",
|
||||
"searchProgress": "Search...",
|
||||
|
|
@ -1132,8 +1141,8 @@
|
|||
"sidebarLicense": "License",
|
||||
"sidebarClients": "Clients (Beta)",
|
||||
"sidebarDomains": "Domains",
|
||||
"enableDockerSocket": "Enable Docker Socket",
|
||||
"enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.",
|
||||
"enableDockerSocket": "Enable Docker Blueprint",
|
||||
"enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt.",
|
||||
"enableDockerSocketLink": "Learn More",
|
||||
"viewDockerContainers": "View Docker Containers",
|
||||
"containersIn": "Containers in {siteName}",
|
||||
|
|
@ -1233,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Update Available",
|
||||
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
||||
"domainPickerEnterDomain": "Domain",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp",
|
||||
"domainPickerPlaceholder": "myapp.example.com",
|
||||
"domainPickerDescription": "Enter the full domain of the resource to see available options.",
|
||||
"domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
|
||||
"domainPickerTabAll": "All",
|
||||
|
|
@ -1391,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protocol",
|
||||
"editInternalResourceDialogSitePort": "Site Port",
|
||||
"editInternalResourceDialogTargetConfiguration": "Target Configuration",
|
||||
"editInternalResourceDialogDestinationIP": "Destination IP",
|
||||
"editInternalResourceDialogDestinationPort": "Destination Port",
|
||||
"editInternalResourceDialogCancel": "Cancel",
|
||||
"editInternalResourceDialogSaveResource": "Save Resource",
|
||||
"editInternalResourceDialogSuccess": "Success",
|
||||
|
|
@ -1423,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Site Port",
|
||||
"createInternalResourceDialogSitePortDescription": "Use this port to access the resource on the site when connected with a client.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Target Configuration",
|
||||
"createInternalResourceDialogDestinationIP": "Destination IP",
|
||||
"createInternalResourceDialogDestinationIPDescription": "The IP address of the resource on the site's network.",
|
||||
"createInternalResourceDialogDestinationPort": "Destination Port",
|
||||
"createInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "The port on the destination IP where the resource is accessible.",
|
||||
"createInternalResourceDialogCancel": "Cancel",
|
||||
"createInternalResourceDialogCreateResource": "Create Resource",
|
||||
|
|
@ -1458,6 +1463,61 @@
|
|||
"autoLoginError": "Auto Login Error",
|
||||
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
|
||||
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.",
|
||||
"internationaldomaindetected": "Detekována mezinárodní doména",
|
||||
"willbestoredas": "Bude uloženo jako:"
|
||||
"managedSelfHosted": {
|
||||
"title": "Managed Self-Hosted",
|
||||
"description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles",
|
||||
"introTitle": "Managed Self-Hosted Pangolin",
|
||||
"introDescription": "is a deployment option designed for people who want simplicity and extra reliability while still keeping their data private and self-hosted.",
|
||||
"introDetail": "With this option, you still run your own Pangolin node — your tunnels, SSL termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Simpler operations",
|
||||
"description": "No need to run your own mail server or set up complex alerting. You'll get health checks and downtime alerts out of the box."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Automatic updates",
|
||||
"description": "The cloud dashboard evolves quickly, so you get new features and bug fixes without having to manually pull new containers every time."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Less maintenance",
|
||||
"description": "No database migrations, backups, or extra infrastructure to manage. We handle that in the cloud."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Cloud failover",
|
||||
"description": "If your node goes down, your tunnels can temporarily fail over to our cloud points of presence until you bring it back online."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "High availability (PoPs)",
|
||||
"description": "You can also attach multiple nodes to your account for redundancy and better performance."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Future enhancements",
|
||||
"description": "We're planning to add more analytics, alerting, and management tools to make your deployment even more robust."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Learn more about the Managed Self-Hosted option in our",
|
||||
"documentation": "documentation"
|
||||
},
|
||||
"convertButton": "Convert This Node to Managed Self-Hosted"
|
||||
},
|
||||
"internationaldomaindetected": "International Domain Detected",
|
||||
"willbestoredas": "Will be stored as:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC provider",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Custom Headers",
|
||||
"headersValidationError": "Headers must be in the format: Header-Name: value.",
|
||||
"domainPickerProvidedDomain": "Provided Domain",
|
||||
"domainPickerFreeProvidedDomain": "Free Provided Domain",
|
||||
"domainPickerVerified": "Verified",
|
||||
"domainPickerUnverified": "Unverified",
|
||||
"domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.",
|
||||
"domainPickerError": "Error",
|
||||
"domainPickerErrorLoadDomains": "Failed to load organization domains",
|
||||
"domainPickerErrorCheckAvailability": "Failed to check domain availability",
|
||||
"domainPickerInvalidSubdomain": "Invalid subdomain",
|
||||
"domainPickerInvalidSubdomainRemoved": "The input \"{sub}\" was removed because it's not valid.",
|
||||
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" could not be made valid for {domain}.",
|
||||
"domainPickerSubdomainSanitized": "Subdomain sanitized",
|
||||
"domainPickerSubdomainCorrected": "\"{sub}\" was corrected to \"{sanitized}\"",
|
||||
"resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Edit file: docker-compose.yml"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "Beim Hinzufügen des Benutzers zur Rolle ist ein Fehler aufgetreten.",
|
||||
"userSaved": "Benutzer gespeichert",
|
||||
"userSavedDescription": "Der Benutzer wurde aktualisiert.",
|
||||
"autoProvisioned": "Automatisch vorgesehen",
|
||||
"autoProvisionedDescription": "Erlaube diesem Benutzer die automatische Verwaltung durch Identitätsanbieter",
|
||||
"accessControlsDescription": "Verwalten Sie, worauf dieser Benutzer in der Organisation zugreifen und was er tun kann",
|
||||
"accessControlsSubmit": "Zugriffskontrollen speichern",
|
||||
"roles": "Rollen",
|
||||
|
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Ungültiges IP-Adressformat",
|
||||
"ipAddressErrorInvalidOctet": "Ungültiges IP-Adress-Oktett",
|
||||
"path": "Pfad",
|
||||
"matchPath": "Unterverzeichnis",
|
||||
"ipAddressRange": "IP-Bereich",
|
||||
"rulesErrorFetch": "Fehler beim Abrufen der Regeln",
|
||||
"rulesErrorFetchDescription": "Beim Abrufen der Regeln ist ein Fehler aufgetreten",
|
||||
|
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Verbunden",
|
||||
"idpErrorConnectingTo": "Es gab ein Problem bei der Verbindung zu {name}. Bitte kontaktieren Sie Ihren Administrator.",
|
||||
"idpErrorNotFound": "IdP nicht gefunden",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Ungültige Einladung",
|
||||
"inviteInvalidDescription": "Der Einladungslink ist ungültig.",
|
||||
"inviteErrorWrongUser": "Einladung ist nicht für diesen Benutzer",
|
||||
|
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Professional Edition erforderlich",
|
||||
"licenseTierProfessionalRequiredDescription": "Diese Funktion ist nur in der Professional Edition verfügbar.",
|
||||
"actionGetOrg": "Organisation abrufen",
|
||||
"updateOrgUser": "Org Benutzer aktualisieren",
|
||||
"createOrgUser": "Org Benutzer erstellen",
|
||||
"actionUpdateOrg": "Organisation aktualisieren",
|
||||
"actionUpdateUser": "Benutzer aktualisieren",
|
||||
"actionGetUser": "Benutzer abrufen",
|
||||
|
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Standort löschen",
|
||||
"actionGetSite": "Standort abrufen",
|
||||
"actionListSites": "Standorte auflisten",
|
||||
"actionApplyBlueprint": "Blaupause anwenden",
|
||||
"setupToken": "Setup-Token",
|
||||
"setupTokenDescription": "Geben Sie das Setup-Token von der Serverkonsole ein.",
|
||||
"setupTokenRequired": "Setup-Token ist erforderlich",
|
||||
|
|
@ -1059,6 +1067,7 @@
|
|||
"actionGetSiteResource": "Site-Ressource abrufen",
|
||||
"actionListSiteResources": "Site-Ressourcen auflisten",
|
||||
"actionUpdateSiteResource": "Site-Ressource aktualisieren",
|
||||
"actionListInvitations": "Einladungen auflisten",
|
||||
"noneSelected": "Keine ausgewählt",
|
||||
"orgNotFound2": "Keine Organisationen gefunden.",
|
||||
"searchProgress": "Suche...",
|
||||
|
|
@ -1132,8 +1141,8 @@
|
|||
"sidebarLicense": "Lizenz",
|
||||
"sidebarClients": "Clients (Beta)",
|
||||
"sidebarDomains": "Domains",
|
||||
"enableDockerSocket": "Docker Socket aktivieren",
|
||||
"enableDockerSocketDescription": "Docker Socket-Erkennung aktivieren, um Container-Informationen zu befüllen. Socket-Pfad muss Newt bereitgestellt werden.",
|
||||
"enableDockerSocket": "Docker Blaupause aktivieren",
|
||||
"enableDockerSocketDescription": "Aktiviere Docker-Socket-Label-Scraping für Blaupausenbeschriftungen. Der Socket-Pfad muss neu angegeben werden.",
|
||||
"enableDockerSocketLink": "Mehr erfahren",
|
||||
"viewDockerContainers": "Docker Container anzeigen",
|
||||
"containersIn": "Container in {siteName}",
|
||||
|
|
@ -1233,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Update verfügbar",
|
||||
"newtUpdateAvailableInfo": "Eine neue Version von Newt ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.",
|
||||
"domainPickerEnterDomain": "Domain",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, oder einfach myapp",
|
||||
"domainPickerPlaceholder": "myapp.example.com",
|
||||
"domainPickerDescription": "Geben Sie die vollständige Domäne der Ressource ein, um verfügbare Optionen zu sehen.",
|
||||
"domainPickerDescriptionSaas": "Geben Sie eine vollständige Domäne, Subdomäne oder einfach einen Namen ein, um verfügbare Optionen zu sehen",
|
||||
"domainPickerTabAll": "Alle",
|
||||
|
|
@ -1391,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protokoll",
|
||||
"editInternalResourceDialogSitePort": "Site-Port",
|
||||
"editInternalResourceDialogTargetConfiguration": "Zielkonfiguration",
|
||||
"editInternalResourceDialogDestinationIP": "Ziel-IP",
|
||||
"editInternalResourceDialogDestinationPort": "Ziel-Port",
|
||||
"editInternalResourceDialogCancel": "Abbrechen",
|
||||
"editInternalResourceDialogSaveResource": "Ressource speichern",
|
||||
"editInternalResourceDialogSuccess": "Erfolg",
|
||||
|
|
@ -1423,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Site-Port",
|
||||
"createInternalResourceDialogSitePortDescription": "Verwenden Sie diesen Port, um bei Verbindung mit einem Client auf die Ressource an der Site zuzugreifen.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Zielkonfiguration",
|
||||
"createInternalResourceDialogDestinationIP": "Ziel-IP",
|
||||
"createInternalResourceDialogDestinationIPDescription": "Die IP-Adresse der Ressource im Netzwerkstandort der Site.",
|
||||
"createInternalResourceDialogDestinationPort": "Ziel-Port",
|
||||
"createInternalResourceDialogDestinationIPDescription": "Die IP-Adresse oder Hostname Adresse der Ressource im Netzwerk der Website.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "Der Port auf der Ziel-IP, unter dem die Ressource zugänglich ist.",
|
||||
"createInternalResourceDialogCancel": "Abbrechen",
|
||||
"createInternalResourceDialogCreateResource": "Ressource erstellen",
|
||||
|
|
@ -1458,6 +1463,61 @@
|
|||
"autoLoginError": "Fehler bei der automatischen Anmeldung",
|
||||
"autoLoginErrorNoRedirectUrl": "Keine Weiterleitungs-URL vom Identitätsanbieter erhalten.",
|
||||
"autoLoginErrorGeneratingUrl": "Fehler beim Generieren der Authentifizierungs-URL.",
|
||||
"internationaldomaindetected": "Internationale Domäne erkannt",
|
||||
"willbestoredas": "Wird gespeichert als:"
|
||||
"managedSelfHosted": {
|
||||
"title": "Verwaltetes Selbsthosted",
|
||||
"description": "Zuverlässiger und wartungsarmer Pangolin Server mit zusätzlichen Glocken und Pfeifen",
|
||||
"introTitle": "Verwalteter selbstgehosteter Pangolin",
|
||||
"introDescription": "ist eine Deployment-Option, die für Personen konzipiert wurde, die Einfachheit und zusätzliche Zuverlässigkeit wünschen, während sie ihre Daten privat und selbstgehostet halten.",
|
||||
"introDetail": "Mit dieser Option haben Sie immer noch Ihren eigenen Pangolin-Knoten – Ihre Tunnel, SSL-Terminierung und Traffic bleiben auf Ihrem Server. Der Unterschied besteht darin, dass Verwaltung und Überwachung über unser Cloud-Dashboard abgewickelt werden, das eine Reihe von Vorteilen freischaltet:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Einfachere Operationen",
|
||||
"description": "Sie brauchen keinen eigenen Mail-Server auszuführen oder komplexe Warnungen einzurichten. Sie erhalten Gesundheitschecks und Ausfallwarnungen aus dem Box."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Automatische Updates",
|
||||
"description": "Das Cloud-Dashboard entwickelt sich schnell, so dass Sie neue Funktionen und Fehlerbehebungen erhalten, ohne jedes Mal neue Container manuell ziehen zu müssen."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Weniger Wartung",
|
||||
"description": "Keine Datenbankmigrationen, Sicherungen oder zusätzliche Infrastruktur zum Verwalten. Wir kümmern uns um das in der Cloud."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Cloud-Ausfall",
|
||||
"description": "Wenn Ihr Knoten runtergeht, können Ihre Tunnel vorübergehend an unsere Cloud-Punkte scheitern, bis Sie ihn wieder online bringen."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "Hohe Verfügbarkeit (PoPs)",
|
||||
"description": "Sie können auch mehrere Knoten an Ihr Konto anhängen, um Redundanz und bessere Leistung zu erzielen."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Zukünftige Verbesserungen",
|
||||
"description": "Wir planen weitere Analyse-, Alarm- und Management-Tools hinzuzufügen, um Ihren Einsatz noch robuster zu machen."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Erfahren Sie mehr über die Managed Self-Hosted Option in unserer",
|
||||
"documentation": "dokumentation"
|
||||
},
|
||||
"convertButton": "Diesen Knoten in Managed Self-Hosted umwandeln"
|
||||
},
|
||||
"internationaldomaindetected": "Internationale Domain erkannt",
|
||||
"willbestoredas": "Wird gespeichert als:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC Provider",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Eigene Kopfzeilen",
|
||||
"headersValidationError": "Header müssen im Format Header-Name: Wert sein.",
|
||||
"domainPickerProvidedDomain": "Angegebene Domain",
|
||||
"domainPickerFreeProvidedDomain": "Kostenlose Domain",
|
||||
"domainPickerVerified": "Verifiziert",
|
||||
"domainPickerUnverified": "Nicht verifiziert",
|
||||
"domainPickerInvalidSubdomainStructure": "Diese Subdomain enthält ungültige Zeichen oder Struktur. Sie wird beim Speichern automatisch bereinigt.",
|
||||
"domainPickerError": "Fehler",
|
||||
"domainPickerErrorLoadDomains": "Fehler beim Laden der Organisations-Domänen",
|
||||
"domainPickerErrorCheckAvailability": "Fehler beim Prüfen der Domain-Verfügbarkeit",
|
||||
"domainPickerInvalidSubdomain": "Ungültige Subdomain",
|
||||
"domainPickerInvalidSubdomainRemoved": "Die Eingabe \"{sub}\" wurde entfernt, weil sie nicht gültig ist.",
|
||||
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" konnte nicht für {domain} gültig gemacht werden.",
|
||||
"domainPickerSubdomainSanitized": "Subdomain bereinigt",
|
||||
"domainPickerSubdomainCorrected": "\"{sub}\" wurde korrigiert zu \"{sanitized}\"",
|
||||
"resourceAddEntrypointsEditFile": "Datei bearbeiten: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Datei bearbeiten: docker-compose.yml"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "An error occurred while adding user to the role.",
|
||||
"userSaved": "User saved",
|
||||
"userSavedDescription": "The user has been updated.",
|
||||
"autoProvisioned": "Auto Provisioned",
|
||||
"autoProvisionedDescription": "Allow this user to be automatically managed by identity provider",
|
||||
"accessControlsDescription": "Manage what this user can access and do in the organization",
|
||||
"accessControlsSubmit": "Save Access Controls",
|
||||
"roles": "Roles",
|
||||
|
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Invalid IP address format",
|
||||
"ipAddressErrorInvalidOctet": "Invalid IP address octet",
|
||||
"path": "Path",
|
||||
"matchPath": "Match Path",
|
||||
"ipAddressRange": "IP Range",
|
||||
"rulesErrorFetch": "Failed to fetch rules",
|
||||
"rulesErrorFetchDescription": "An error occurred while fetching rules",
|
||||
|
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Connected",
|
||||
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
|
||||
"idpErrorNotFound": "IdP not found",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Invalid Invite",
|
||||
"inviteInvalidDescription": "The invite link is invalid.",
|
||||
"inviteErrorWrongUser": "Invite is not for this user",
|
||||
|
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Professional Edition Required",
|
||||
"licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.",
|
||||
"actionGetOrg": "Get Organization",
|
||||
"updateOrgUser": "Update Org User",
|
||||
"createOrgUser": "Create Org User",
|
||||
"actionUpdateOrg": "Update Organization",
|
||||
"actionUpdateUser": "Update User",
|
||||
"actionGetUser": "Get User",
|
||||
|
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Delete Site",
|
||||
"actionGetSite": "Get Site",
|
||||
"actionListSites": "List Sites",
|
||||
"actionApplyBlueprint": "Apply Blueprint",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenDescription": "Enter the setup token from the server console.",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
|
|
@ -1133,8 +1141,8 @@
|
|||
"sidebarLicense": "License",
|
||||
"sidebarClients": "Clients (Beta)",
|
||||
"sidebarDomains": "Domains",
|
||||
"enableDockerSocket": "Enable Docker Socket",
|
||||
"enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.",
|
||||
"enableDockerSocket": "Enable Docker Blueprint",
|
||||
"enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt.",
|
||||
"enableDockerSocketLink": "Learn More",
|
||||
"viewDockerContainers": "View Docker Containers",
|
||||
"containersIn": "Containers in {siteName}",
|
||||
|
|
@ -1234,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Update Available",
|
||||
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
||||
"domainPickerEnterDomain": "Domain",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp",
|
||||
"domainPickerPlaceholder": "myapp.example.com",
|
||||
"domainPickerDescription": "Enter the full domain of the resource to see available options.",
|
||||
"domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
|
||||
"domainPickerTabAll": "All",
|
||||
|
|
@ -1392,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protocol",
|
||||
"editInternalResourceDialogSitePort": "Site Port",
|
||||
"editInternalResourceDialogTargetConfiguration": "Target Configuration",
|
||||
"editInternalResourceDialogDestinationIP": "Destination IP",
|
||||
"editInternalResourceDialogDestinationPort": "Destination Port",
|
||||
"editInternalResourceDialogCancel": "Cancel",
|
||||
"editInternalResourceDialogSaveResource": "Save Resource",
|
||||
"editInternalResourceDialogSuccess": "Success",
|
||||
|
|
@ -1424,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Site Port",
|
||||
"createInternalResourceDialogSitePortDescription": "Use this port to access the resource on the site when connected with a client.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Target Configuration",
|
||||
"createInternalResourceDialogDestinationIP": "Destination IP",
|
||||
"createInternalResourceDialogDestinationIPDescription": "The IP address of the resource on the site's network.",
|
||||
"createInternalResourceDialogDestinationPort": "Destination Port",
|
||||
"createInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "The port on the destination IP where the resource is accessible.",
|
||||
"createInternalResourceDialogCancel": "Cancel",
|
||||
"createInternalResourceDialogCreateResource": "Create Resource",
|
||||
|
|
@ -1496,5 +1500,24 @@
|
|||
"convertButton": "Convert This Node to Managed Self-Hosted"
|
||||
},
|
||||
"internationaldomaindetected": "International Domain Detected",
|
||||
"willbestoredas": "Will be stored as:"
|
||||
"willbestoredas": "Will be stored as:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC provider",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Custom Headers",
|
||||
"headersValidationError": "Headers must be in the format: Header-Name: value.",
|
||||
"domainPickerProvidedDomain": "Provided Domain",
|
||||
"domainPickerFreeProvidedDomain": "Free Provided Domain",
|
||||
"domainPickerVerified": "Verified",
|
||||
"domainPickerUnverified": "Unverified",
|
||||
"domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.",
|
||||
"domainPickerError": "Error",
|
||||
"domainPickerErrorLoadDomains": "Failed to load organization domains",
|
||||
"domainPickerErrorCheckAvailability": "Failed to check domain availability",
|
||||
"domainPickerInvalidSubdomain": "Invalid subdomain",
|
||||
"domainPickerInvalidSubdomainRemoved": "The input \"{sub}\" was removed because it's not valid.",
|
||||
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" could not be made valid for {domain}.",
|
||||
"domainPickerSubdomainSanitized": "Subdomain sanitized",
|
||||
"domainPickerSubdomainCorrected": "\"{sub}\" was corrected to \"{sanitized}\"",
|
||||
"resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Edit file: docker-compose.yml"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "Ocurrió un error mientras se añadía el usuario al rol.",
|
||||
"userSaved": "Usuario guardado",
|
||||
"userSavedDescription": "El usuario ha sido actualizado.",
|
||||
"autoProvisioned": "Auto asegurado",
|
||||
"autoProvisionedDescription": "Permitir a este usuario ser administrado automáticamente por el proveedor de identidad",
|
||||
"accessControlsDescription": "Administrar lo que este usuario puede acceder y hacer en la organización",
|
||||
"accessControlsSubmit": "Guardar controles de acceso",
|
||||
"roles": "Roles",
|
||||
|
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Formato de dirección IP inválido",
|
||||
"ipAddressErrorInvalidOctet": "Octet de dirección IP no válido",
|
||||
"path": "Ruta",
|
||||
"matchPath": "Coincidir ruta",
|
||||
"ipAddressRange": "Rango IP",
|
||||
"rulesErrorFetch": "Error al obtener las reglas",
|
||||
"rulesErrorFetchDescription": "Se ha producido un error al recuperar las reglas",
|
||||
|
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Conectado",
|
||||
"idpErrorConnectingTo": "Hubo un problema al conectar con {name}. Por favor, póngase en contacto con su administrador.",
|
||||
"idpErrorNotFound": "IdP no encontrado",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Invitación inválida",
|
||||
"inviteInvalidDescription": "El enlace de invitación no es válido.",
|
||||
"inviteErrorWrongUser": "La invitación no es para este usuario",
|
||||
|
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Edición Profesional requerida",
|
||||
"licenseTierProfessionalRequiredDescription": "Esta característica sólo está disponible en la Edición Profesional.",
|
||||
"actionGetOrg": "Obtener organización",
|
||||
"updateOrgUser": "Actualizar usuario Org",
|
||||
"createOrgUser": "Crear usuario Org",
|
||||
"actionUpdateOrg": "Actualizar organización",
|
||||
"actionUpdateUser": "Actualizar usuario",
|
||||
"actionGetUser": "Obtener usuario",
|
||||
|
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Eliminar sitio",
|
||||
"actionGetSite": "Obtener sitio",
|
||||
"actionListSites": "Listar sitios",
|
||||
"actionApplyBlueprint": "Aplicar plano",
|
||||
"setupToken": "Configuración de token",
|
||||
"setupTokenDescription": "Ingrese el token de configuración desde la consola del servidor.",
|
||||
"setupTokenRequired": "Se requiere el token de configuración",
|
||||
|
|
@ -1059,6 +1067,7 @@
|
|||
"actionGetSiteResource": "Obtener recurso del sitio",
|
||||
"actionListSiteResources": "Listar recursos del sitio",
|
||||
"actionUpdateSiteResource": "Actualizar recurso del sitio",
|
||||
"actionListInvitations": "Listar invitaciones",
|
||||
"noneSelected": "Ninguno seleccionado",
|
||||
"orgNotFound2": "No se encontraron organizaciones.",
|
||||
"searchProgress": "Buscar...",
|
||||
|
|
@ -1132,8 +1141,8 @@
|
|||
"sidebarLicense": "Licencia",
|
||||
"sidebarClients": "Clientes (Beta)",
|
||||
"sidebarDomains": "Dominios",
|
||||
"enableDockerSocket": "Habilitar conector Docker",
|
||||
"enableDockerSocketDescription": "Habilitar el descubrimiento de Docker Socket para completar la información del contenedor. La ruta del socket debe proporcionarse a Newt.",
|
||||
"enableDockerSocket": "Habilitar Plano Docker",
|
||||
"enableDockerSocketDescription": "Activar el raspado de etiquetas de Socket Docker para etiquetas de planos. La ruta del Socket debe proporcionarse a Newt.",
|
||||
"enableDockerSocketLink": "Saber más",
|
||||
"viewDockerContainers": "Ver contenedores Docker",
|
||||
"containersIn": "Contenedores en {siteName}",
|
||||
|
|
@ -1233,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Nueva actualización disponible",
|
||||
"newtUpdateAvailableInfo": "Hay una nueva versión de Newt disponible. Actualice a la última versión para la mejor experiencia.",
|
||||
"domainPickerEnterDomain": "Dominio",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.miDominio.com, o solo myapp",
|
||||
"domainPickerPlaceholder": "miapp.ejemplo.com",
|
||||
"domainPickerDescription": "Ingresa el dominio completo del recurso para ver las opciones disponibles.",
|
||||
"domainPickerDescriptionSaas": "Ingresa un dominio completo, subdominio o simplemente un nombre para ver las opciones disponibles",
|
||||
"domainPickerTabAll": "Todo",
|
||||
|
|
@ -1391,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protocolo",
|
||||
"editInternalResourceDialogSitePort": "Puerto del sitio",
|
||||
"editInternalResourceDialogTargetConfiguration": "Configuración de objetivos",
|
||||
"editInternalResourceDialogDestinationIP": "IP de destino",
|
||||
"editInternalResourceDialogDestinationPort": "Puerto de destino",
|
||||
"editInternalResourceDialogCancel": "Cancelar",
|
||||
"editInternalResourceDialogSaveResource": "Guardar recurso",
|
||||
"editInternalResourceDialogSuccess": "Éxito",
|
||||
|
|
@ -1423,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Puerto del sitio",
|
||||
"createInternalResourceDialogSitePortDescription": "Use este puerto para acceder al recurso en el sitio cuando se conecta con un cliente.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Configuración de objetivos",
|
||||
"createInternalResourceDialogDestinationIP": "IP de destino",
|
||||
"createInternalResourceDialogDestinationIPDescription": "La dirección IP del recurso en la red del sitio.",
|
||||
"createInternalResourceDialogDestinationPort": "Puerto de destino",
|
||||
"createInternalResourceDialogDestinationIPDescription": "La dirección IP o nombre de host del recurso en la red del sitio.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "El puerto en la IP de destino donde el recurso es accesible.",
|
||||
"createInternalResourceDialogCancel": "Cancelar",
|
||||
"createInternalResourceDialogCreateResource": "Crear recurso",
|
||||
|
|
@ -1458,6 +1463,61 @@
|
|||
"autoLoginError": "Error de inicio de sesión automático",
|
||||
"autoLoginErrorNoRedirectUrl": "No se recibió URL de redirección del proveedor de identidad.",
|
||||
"autoLoginErrorGeneratingUrl": "Error al generar URL de autenticación.",
|
||||
"internationaldomaindetected": "Dominio internacional detectado",
|
||||
"willbestoredas": "Se almacenará como: "
|
||||
"managedSelfHosted": {
|
||||
"title": "Autogestionado",
|
||||
"description": "Servidor Pangolin autoalojado más fiable y de bajo mantenimiento con campanas y silbidos extra",
|
||||
"introTitle": "Pangolin autogestionado",
|
||||
"introDescription": "es una opción de despliegue diseñada para personas que quieren simplicidad y fiabilidad extra mientras mantienen sus datos privados y autoalojados.",
|
||||
"introDetail": "Con esta opción, todavía ejecuta su propio nodo Pangolin, sus túneles, terminación SSL y tráfico permanecen en su servidor. La diferencia es que la gestión y el control se gestionan a través de nuestro panel de control en la nube, que desbloquea una serie de ventajas:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Operaciones simples",
|
||||
"description": "No necesitas ejecutar tu propio servidor de correo o configurar alertas complejas. Recibirás cheques de salud y alertas de tiempo de inactividad."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Actualizaciones automáticas",
|
||||
"description": "El tablero de la nube evolucionará rápidamente, por lo que obtendrá nuevas características y correcciones de errores sin tener que extraer manualmente nuevos contenedores cada vez."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Menos mantenimiento",
|
||||
"description": "No hay migraciones de base de datos, copias de seguridad o infraestructura extra para administrar. Lo manejamos en la nube."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Fallo en la nube",
|
||||
"description": "Si tu nodo cae, tus túneles pueden fallar temporalmente a nuestros puntos de presencia en la nube hasta que lo vuelvas a conectar."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "Alta disponibilidad (PoPs)",
|
||||
"description": "También puede adjuntar múltiples nodos a su cuenta para redundancia y mejor rendimiento."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Mejoras futuras",
|
||||
"description": "Estamos planeando añadir más herramientas analíticas, alertas y de administración para hacer su despliegue aún más robusto."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Aprenda más acerca de la opción de autoalojamiento administrado en nuestra",
|
||||
"documentation": "documentación"
|
||||
},
|
||||
"convertButton": "Convierte este nodo a autoalojado administrado"
|
||||
},
|
||||
"internationaldomaindetected": "Dominio Internacional detectado",
|
||||
"willbestoredas": "Se almacenará como:",
|
||||
"idpGoogleDescription": "Proveedor OAuth2/OIDC de Google",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Cabeceras personalizadas",
|
||||
"headersValidationError": "Los encabezados deben estar en el formato: Nombre de cabecera: valor.",
|
||||
"domainPickerProvidedDomain": "Dominio proporcionado",
|
||||
"domainPickerFreeProvidedDomain": "Dominio proporcionado gratis",
|
||||
"domainPickerVerified": "Verificado",
|
||||
"domainPickerUnverified": "Sin verificar",
|
||||
"domainPickerInvalidSubdomainStructure": "Este subdominio contiene caracteres o estructura no válidos. Se limpiará automáticamente al guardar.",
|
||||
"domainPickerError": "Error",
|
||||
"domainPickerErrorLoadDomains": "Error al cargar los dominios de la organización",
|
||||
"domainPickerErrorCheckAvailability": "No se pudo comprobar la disponibilidad del dominio",
|
||||
"domainPickerInvalidSubdomain": "Subdominio inválido",
|
||||
"domainPickerInvalidSubdomainRemoved": "La entrada \"{sub}\" fue eliminada porque no es válida.",
|
||||
"domainPickerInvalidSubdomainCannotMakeValid": "No se ha podido hacer válido \"{sub}\" para {domain}.",
|
||||
"domainPickerSubdomainSanitized": "Subdominio saneado",
|
||||
"domainPickerSubdomainCorrected": "\"{sub}\" fue corregido a \"{sanitized}\"",
|
||||
"resourceAddEntrypointsEditFile": "Editar archivo: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Editar archivo: docker-compose.yml"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
"setupErrorIdentifier": "L'ID de l'organisation est déjà pris. Veuillez en choisir un autre.",
|
||||
"componentsErrorNoMemberCreate": "Vous n'êtes actuellement membre d'aucune organisation. Créez une organisation pour commencer.",
|
||||
"componentsErrorNoMember": "Vous n'êtes actuellement membre d'aucune organisation.",
|
||||
"welcome": "Bienvenue à Pangolin",
|
||||
"welcome": "Bienvenue sur Pangolin",
|
||||
"welcomeTo": "Bienvenue chez",
|
||||
"componentsCreateOrg": "Créer une organisation",
|
||||
"componentsMember": "Vous êtes membre de {count, plural, =0 {aucune organisation} one {une organisation} other {# organisations}}.",
|
||||
|
|
@ -34,13 +34,13 @@
|
|||
"confirmPassword": "Confirmer le mot de passe",
|
||||
"createAccount": "Créer un compte",
|
||||
"viewSettings": "Afficher les paramètres",
|
||||
"delete": "Supprimez",
|
||||
"delete": "Supprimer",
|
||||
"name": "Nom",
|
||||
"online": "En ligne",
|
||||
"offline": "Hors ligne",
|
||||
"site": "Site",
|
||||
"dataIn": "Données dans",
|
||||
"dataOut": "Données épuisées",
|
||||
"dataIn": "Données reçues",
|
||||
"dataOut": "Données envoyées",
|
||||
"connectionType": "Type de connexion",
|
||||
"tunnelType": "Type de tunnel",
|
||||
"local": "Locale",
|
||||
|
|
@ -175,7 +175,7 @@
|
|||
"resourceHTTPSSettingsDescription": "Configurer comment votre ressource sera accédée via HTTPS",
|
||||
"domainType": "Type de domaine",
|
||||
"subdomain": "Sous-domaine",
|
||||
"baseDomain": "Domaine de base",
|
||||
"baseDomain": "Domaine racine",
|
||||
"subdomnainDescription": "Le sous-domaine où votre ressource sera accessible.",
|
||||
"resourceRawSettings": "Paramètres TCP/UDP",
|
||||
"resourceRawSettingsDescription": "Configurer comment votre ressource sera accédée via TCP/UDP",
|
||||
|
|
@ -309,7 +309,7 @@
|
|||
"numberOfSites": "Nombre de sites",
|
||||
"licenseKeySearch": "Rechercher des clés de licence...",
|
||||
"licenseKeyAdd": "Ajouter une clé de licence",
|
||||
"type": "Type de texte",
|
||||
"type": "Type",
|
||||
"licenseKeyRequired": "La clé de licence est requise",
|
||||
"licenseTermsAgree": "Vous devez accepter les conditions de licence",
|
||||
"licenseErrorKeyLoad": "Impossible de charger les clés de licence",
|
||||
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "Une erreur s'est produite lors de l'ajout de l'utilisateur au rôle.",
|
||||
"userSaved": "Utilisateur enregistré",
|
||||
"userSavedDescription": "L'utilisateur a été mis à jour.",
|
||||
"autoProvisioned": "Auto-provisionné",
|
||||
"autoProvisionedDescription": "Permettre à cet utilisateur d'être géré automatiquement par le fournisseur d'identité",
|
||||
"accessControlsDescription": "Gérer ce que cet utilisateur peut accéder et faire dans l'organisation",
|
||||
"accessControlsSubmit": "Enregistrer les contrôles d'accès",
|
||||
"roles": "Rôles",
|
||||
|
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Format d'adresse IP invalide",
|
||||
"ipAddressErrorInvalidOctet": "Octet d'adresse IP invalide",
|
||||
"path": "Chemin",
|
||||
"matchPath": "Chemin de correspondance",
|
||||
"ipAddressRange": "Plage IP",
|
||||
"rulesErrorFetch": "Échec de la récupération des règles",
|
||||
"rulesErrorFetchDescription": "Une erreur s'est produite lors de la récupération des règles",
|
||||
|
|
@ -595,7 +598,7 @@
|
|||
"newtId": "ID Newt",
|
||||
"newtSecretKey": "Clé secrète Newt",
|
||||
"architecture": "Architecture",
|
||||
"sites": "Espaces",
|
||||
"sites": "Sites",
|
||||
"siteWgAnyClients": "Utilisez n'importe quel client WireGuard pour vous connecter. Vous devrez adresser vos ressources internes en utilisant l'IP du pair.",
|
||||
"siteWgCompatibleAllClients": "Compatible avec tous les clients WireGuard",
|
||||
"siteWgManualConfigurationRequired": "Configuration manuelle requise",
|
||||
|
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Connecté",
|
||||
"idpErrorConnectingTo": "Un problème est survenu lors de la connexion à {name}. Veuillez contacter votre administrateur.",
|
||||
"idpErrorNotFound": "IdP introuvable",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Invitation invalide",
|
||||
"inviteInvalidDescription": "Le lien d'invitation n'est pas valide.",
|
||||
"inviteErrorWrongUser": "L'invitation n'est pas pour cet utilisateur",
|
||||
|
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Édition Professionnelle Requise",
|
||||
"licenseTierProfessionalRequiredDescription": "Cette fonctionnalité n'est disponible que dans l'Édition Professionnelle.",
|
||||
"actionGetOrg": "Obtenir l'organisation",
|
||||
"updateOrgUser": "Mise à jour de l'utilisateur Org",
|
||||
"createOrgUser": "Créer un utilisateur Org",
|
||||
"actionUpdateOrg": "Mettre à jour l'organisation",
|
||||
"actionUpdateUser": "Mettre à jour l'utilisateur",
|
||||
"actionGetUser": "Obtenir l'utilisateur",
|
||||
|
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Supprimer un site",
|
||||
"actionGetSite": "Obtenir un site",
|
||||
"actionListSites": "Lister les sites",
|
||||
"actionApplyBlueprint": "Appliquer le Plan",
|
||||
"setupToken": "Jeton de configuration",
|
||||
"setupTokenDescription": "Entrez le jeton de configuration depuis la console du serveur.",
|
||||
"setupTokenRequired": "Le jeton de configuration est requis.",
|
||||
|
|
@ -1059,6 +1067,7 @@
|
|||
"actionGetSiteResource": "Obtenir une ressource de site",
|
||||
"actionListSiteResources": "Lister les ressources de site",
|
||||
"actionUpdateSiteResource": "Mettre à jour une ressource de site",
|
||||
"actionListInvitations": "Lister les invitations",
|
||||
"noneSelected": "Aucune sélection",
|
||||
"orgNotFound2": "Aucune organisation trouvée.",
|
||||
"searchProgress": "Rechercher...",
|
||||
|
|
@ -1119,7 +1128,7 @@
|
|||
"sidebarOverview": "Aperçu",
|
||||
"sidebarHome": "Domicile",
|
||||
"sidebarSites": "Espaces",
|
||||
"sidebarResources": "Ressource",
|
||||
"sidebarResources": "Ressources",
|
||||
"sidebarAccessControl": "Contrôle d'accès",
|
||||
"sidebarUsers": "Utilisateurs",
|
||||
"sidebarInvitations": "Invitations",
|
||||
|
|
@ -1132,8 +1141,8 @@
|
|||
"sidebarLicense": "Licence",
|
||||
"sidebarClients": "Clients (Bêta)",
|
||||
"sidebarDomains": "Domaines",
|
||||
"enableDockerSocket": "Activer Docker Socket",
|
||||
"enableDockerSocketDescription": "Activer la découverte Docker Socket pour remplir les informations du conteneur. Le chemin du socket doit être fourni à Newt.",
|
||||
"enableDockerSocket": "Activer le Plan Docker",
|
||||
"enableDockerSocketDescription": "Activer le ramassage d'étiquettes de socket Docker pour les étiquettes de plan. Le chemin de socket doit être fourni à Newt.",
|
||||
"enableDockerSocketLink": "En savoir plus",
|
||||
"viewDockerContainers": "Voir les conteneurs Docker",
|
||||
"containersIn": "Conteneurs en {siteName}",
|
||||
|
|
@ -1233,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Mise à jour disponible",
|
||||
"newtUpdateAvailableInfo": "Une nouvelle version de Newt est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.",
|
||||
"domainPickerEnterDomain": "Domaine",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, ou simplement myapp",
|
||||
"domainPickerPlaceholder": "monapp.exemple.com",
|
||||
"domainPickerDescription": "Entrez le domaine complet de la ressource pour voir les options disponibles.",
|
||||
"domainPickerDescriptionSaas": "Entrez un domaine complet, un sous-domaine ou juste un nom pour voir les options disponibles",
|
||||
"domainPickerTabAll": "Tous",
|
||||
|
|
@ -1391,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protocole",
|
||||
"editInternalResourceDialogSitePort": "Port du site",
|
||||
"editInternalResourceDialogTargetConfiguration": "Configuration de la cible",
|
||||
"editInternalResourceDialogDestinationIP": "IP de destination",
|
||||
"editInternalResourceDialogDestinationPort": "Port de destination",
|
||||
"editInternalResourceDialogCancel": "Abandonner",
|
||||
"editInternalResourceDialogSaveResource": "Enregistrer la ressource",
|
||||
"editInternalResourceDialogSuccess": "Succès",
|
||||
|
|
@ -1423,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Port du site",
|
||||
"createInternalResourceDialogSitePortDescription": "Utilisez ce port pour accéder à la ressource sur le site lors de la connexion avec un client.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Configuration de la cible",
|
||||
"createInternalResourceDialogDestinationIP": "IP de destination",
|
||||
"createInternalResourceDialogDestinationIPDescription": "L'adresse IP de la ressource sur le réseau du site.",
|
||||
"createInternalResourceDialogDestinationPort": "Port de destination",
|
||||
"createInternalResourceDialogDestinationIPDescription": "L'adresse IP ou le nom d'hôte de la ressource sur le réseau du site.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "Le port sur l'IP de destination où la ressource est accessible.",
|
||||
"createInternalResourceDialogCancel": "Abandonner",
|
||||
"createInternalResourceDialogCreateResource": "Créer une ressource",
|
||||
|
|
@ -1458,6 +1463,61 @@
|
|||
"autoLoginError": "Erreur de connexion automatique",
|
||||
"autoLoginErrorNoRedirectUrl": "Aucune URL de redirection reçue du fournisseur d'identité.",
|
||||
"autoLoginErrorGeneratingUrl": "Échec de la génération de l'URL d'authentification.",
|
||||
"managedSelfHosted": {
|
||||
"title": "Gestion autonome",
|
||||
"description": "Serveur Pangolin auto-hébergé avec des cloches et des sifflets supplémentaires",
|
||||
"introTitle": "Pangolin auto-hébergé géré",
|
||||
"introDescription": "est une option de déploiement conçue pour les personnes qui veulent de la simplicité et de la fiabilité tout en gardant leurs données privées et auto-hébergées.",
|
||||
"introDetail": "Avec cette option, vous exécutez toujours votre propre nœud Pangolin — vos tunnels, la terminaison SSL et le trafic restent sur votre serveur. La différence est que la gestion et la surveillance sont gérées via notre tableau de bord du cloud, qui déverrouille un certain nombre d'avantages :",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Opérations plus simples",
|
||||
"description": "Pas besoin de faire tourner votre propre serveur de messagerie ou de configurer des alertes complexes. Vous obtiendrez des contrôles de santé et des alertes de temps d'arrêt par la suite."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Mises à jour automatiques",
|
||||
"description": "Le tableau de bord du cloud évolue rapidement, de sorte que vous obtenez de nouvelles fonctionnalités et des corrections de bugs sans avoir à extraire manuellement de nouveaux conteneurs à chaque fois."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Moins de maintenance",
|
||||
"description": "Aucune migration de base de données, sauvegarde ou infrastructure supplémentaire à gérer. Nous gérons cela dans le cloud."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Basculement du Cloud",
|
||||
"description": "Si votre nœud descend, vos tunnels peuvent temporairement échouer jusqu'à ce que vous le rapatriez en ligne."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "Haute disponibilité (PoPs)",
|
||||
"description": "Vous pouvez également attacher plusieurs nœuds à votre compte pour une redondance et de meilleures performances."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Améliorations futures",
|
||||
"description": "Nous prévoyons d'ajouter plus d'outils d'analyse, d'alerte et de gestion pour rendre votre déploiement encore plus robuste."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "En savoir plus sur l'option Auto-Hébergement géré dans notre",
|
||||
"documentation": "documentation"
|
||||
},
|
||||
"convertButton": "Convertir ce noeud en auto-hébergé géré"
|
||||
},
|
||||
"internationaldomaindetected": "Domaine international détecté",
|
||||
"willbestoredas": "Sera stocké comme:"
|
||||
"willbestoredas": "Sera stocké comme :",
|
||||
"idpGoogleDescription": "Fournisseur Google OAuth2/OIDC",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "En-têtes personnalisés",
|
||||
"headersValidationError": "Les entêtes doivent être au format : Header-Name: valeur.",
|
||||
"domainPickerProvidedDomain": "Domaine fourni",
|
||||
"domainPickerFreeProvidedDomain": "Domaine fourni gratuitement",
|
||||
"domainPickerVerified": "Vérifié",
|
||||
"domainPickerUnverified": "Non vérifié",
|
||||
"domainPickerInvalidSubdomainStructure": "Ce sous-domaine contient des caractères ou une structure non valide. Il sera automatiquement nettoyé lorsque vous enregistrez.",
|
||||
"domainPickerError": "Erreur",
|
||||
"domainPickerErrorLoadDomains": "Impossible de charger les domaines de l'organisation",
|
||||
"domainPickerErrorCheckAvailability": "Impossible de vérifier la disponibilité du domaine",
|
||||
"domainPickerInvalidSubdomain": "Sous-domaine invalide",
|
||||
"domainPickerInvalidSubdomainRemoved": "L'entrée \"{sub}\" a été supprimée car elle n'est pas valide.",
|
||||
"domainPickerInvalidSubdomainCannotMakeValid": "La «{sub}» n'a pas pu être validée pour {domain}.",
|
||||
"domainPickerSubdomainSanitized": "Sous-domaine nettoyé",
|
||||
"domainPickerSubdomainCorrected": "\"{sub}\" a été corrigé à \"{sanitized}\"",
|
||||
"resourceAddEntrypointsEditFile": "Modifier le fichier : config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Modifier le fichier : docker-compose.yml"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "Si è verificato un errore durante l'aggiunta dell'utente al ruolo.",
|
||||
"userSaved": "Utente salvato",
|
||||
"userSavedDescription": "L'utente è stato aggiornato.",
|
||||
"autoProvisioned": "Auto Provisioned",
|
||||
"autoProvisionedDescription": "Permetti a questo utente di essere gestito automaticamente dal provider di identità",
|
||||
"accessControlsDescription": "Gestisci cosa questo utente può accedere e fare nell'organizzazione",
|
||||
"accessControlsSubmit": "Salva Controlli di Accesso",
|
||||
"roles": "Ruoli",
|
||||
|
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Formato indirizzo IP non valido",
|
||||
"ipAddressErrorInvalidOctet": "Ottetto indirizzo IP non valido",
|
||||
"path": "Percorso",
|
||||
"matchPath": "Corrispondenza Tracciato",
|
||||
"ipAddressRange": "Intervallo IP",
|
||||
"rulesErrorFetch": "Impossibile recuperare le regole",
|
||||
"rulesErrorFetchDescription": "Si è verificato un errore durante il recupero delle regole",
|
||||
|
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Connesso",
|
||||
"idpErrorConnectingTo": "Si è verificato un problema durante la connessione a {name}. Contatta il tuo amministratore.",
|
||||
"idpErrorNotFound": "IdP non trovato",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Invito Non Valido",
|
||||
"inviteInvalidDescription": "Il link di invito non è valido.",
|
||||
"inviteErrorWrongUser": "L'invito non è per questo utente",
|
||||
|
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Edizione Professional Richiesta",
|
||||
"licenseTierProfessionalRequiredDescription": "Questa funzionalità è disponibile solo nell'Edizione Professional.",
|
||||
"actionGetOrg": "Ottieni Organizzazione",
|
||||
"updateOrgUser": "Aggiorna Utente Org",
|
||||
"createOrgUser": "Crea Utente Org",
|
||||
"actionUpdateOrg": "Aggiorna Organizzazione",
|
||||
"actionUpdateUser": "Aggiorna Utente",
|
||||
"actionGetUser": "Ottieni Utente",
|
||||
|
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Elimina Sito",
|
||||
"actionGetSite": "Ottieni Sito",
|
||||
"actionListSites": "Elenca Siti",
|
||||
"actionApplyBlueprint": "Applica Progetto",
|
||||
"setupToken": "Configura Token",
|
||||
"setupTokenDescription": "Inserisci il token di configurazione dalla console del server.",
|
||||
"setupTokenRequired": "Il token di configurazione è richiesto",
|
||||
|
|
@ -1059,6 +1067,7 @@
|
|||
"actionGetSiteResource": "Ottieni Risorsa del Sito",
|
||||
"actionListSiteResources": "Elenca Risorse del Sito",
|
||||
"actionUpdateSiteResource": "Aggiorna Risorsa del Sito",
|
||||
"actionListInvitations": "Elenco Inviti",
|
||||
"noneSelected": "Nessuna selezione",
|
||||
"orgNotFound2": "Nessuna organizzazione trovata.",
|
||||
"searchProgress": "Ricerca...",
|
||||
|
|
@ -1132,8 +1141,8 @@
|
|||
"sidebarLicense": "Licenza",
|
||||
"sidebarClients": "Clienti (Beta)",
|
||||
"sidebarDomains": "Domini",
|
||||
"enableDockerSocket": "Abilita Docker Socket",
|
||||
"enableDockerSocketDescription": "Abilita il rilevamento Docker Socket per popolare le informazioni del contenitore. Il percorso del socket deve essere fornito a Newt.",
|
||||
"enableDockerSocket": "Abilita Progetto Docker",
|
||||
"enableDockerSocketDescription": "Abilita la raschiatura dell'etichetta Docker Socket per le etichette dei progetti. Il percorso del socket deve essere fornito a Newt.",
|
||||
"enableDockerSocketLink": "Scopri di più",
|
||||
"viewDockerContainers": "Visualizza Contenitori Docker",
|
||||
"containersIn": "Contenitori in {siteName}",
|
||||
|
|
@ -1233,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Aggiornamento Disponibile",
|
||||
"newtUpdateAvailableInfo": "È disponibile una nuova versione di Newt. Si prega di aggiornare all'ultima versione per la migliore esperienza.",
|
||||
"domainPickerEnterDomain": "Dominio",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, o semplicemente myapp",
|
||||
"domainPickerPlaceholder": "myapp.example.com",
|
||||
"domainPickerDescription": "Inserisci il dominio completo della risorsa per vedere le opzioni disponibili.",
|
||||
"domainPickerDescriptionSaas": "Inserisci un dominio completo, un sottodominio o semplicemente un nome per vedere le opzioni disponibili",
|
||||
"domainPickerTabAll": "Tutti",
|
||||
|
|
@ -1391,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protocollo",
|
||||
"editInternalResourceDialogSitePort": "Porta del Sito",
|
||||
"editInternalResourceDialogTargetConfiguration": "Configurazione Target",
|
||||
"editInternalResourceDialogDestinationIP": "IP di Destinazione",
|
||||
"editInternalResourceDialogDestinationPort": "Porta di Destinazione",
|
||||
"editInternalResourceDialogCancel": "Annulla",
|
||||
"editInternalResourceDialogSaveResource": "Salva Risorsa",
|
||||
"editInternalResourceDialogSuccess": "Successo",
|
||||
|
|
@ -1423,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Porta del Sito",
|
||||
"createInternalResourceDialogSitePortDescription": "Usa questa porta per accedere alla risorsa nel sito quando sei connesso con un client.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Configurazione Target",
|
||||
"createInternalResourceDialogDestinationIP": "IP di Destinazione",
|
||||
"createInternalResourceDialogDestinationIPDescription": "L'indirizzo IP della risorsa sulla rete del sito.",
|
||||
"createInternalResourceDialogDestinationPort": "Porta di Destinazione",
|
||||
"createInternalResourceDialogDestinationIPDescription": "L'indirizzo IP o hostname della risorsa nella rete del sito.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "La porta sull'IP di destinazione dove la risorsa è accessibile.",
|
||||
"createInternalResourceDialogCancel": "Annulla",
|
||||
"createInternalResourceDialogCreateResource": "Crea Risorsa",
|
||||
|
|
@ -1458,6 +1463,61 @@
|
|||
"autoLoginError": "Errore di Accesso Automatico",
|
||||
"autoLoginErrorNoRedirectUrl": "Nessun URL di reindirizzamento ricevuto dal provider di identità.",
|
||||
"autoLoginErrorGeneratingUrl": "Impossibile generare l'URL di autenticazione.",
|
||||
"internationaldomaindetected": "Rilevato dominio internazionale",
|
||||
"willbestoredas": "Verrà archiviato come:"
|
||||
"managedSelfHosted": {
|
||||
"title": "Gestito Auto-Ospitato",
|
||||
"description": "Server Pangolin self-hosted più affidabile e a bassa manutenzione con campanelli e fischietti extra",
|
||||
"introTitle": "Managed Self-Hosted Pangolin",
|
||||
"introDescription": "è un'opzione di distribuzione progettata per le persone che vogliono la semplicità e l'affidabilità extra mantenendo i loro dati privati e self-hosted.",
|
||||
"introDetail": "Con questa opzione, esegui ancora il tuo nodo Pangolin — i tunnel, la terminazione SSL e il traffico rimangono tutti sul tuo server. La differenza è che la gestione e il monitoraggio sono gestiti attraverso il nostro cruscotto cloud, che sblocca una serie di vantaggi:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Operazioni più semplici",
|
||||
"description": "Non è necessario eseguire il proprio server di posta o impostare un avviso complesso. Otterrai controlli di salute e avvisi di inattività fuori dalla casella."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Aggiornamenti automatici",
|
||||
"description": "Il cruscotto cloud si evolve rapidamente, in modo da ottenere nuove funzionalità e correzioni di bug senza dover tirare manualmente nuovi contenitori ogni volta."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Meno manutenzione",
|
||||
"description": "Nessuna migrazione di database, backup o infrastruttura extra da gestire. Gestiamo questo problema nel cloud."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "failover del cloud",
|
||||
"description": "Se il tuo nodo scende, i tuoi tunnel possono temporaneamente fallire nei nostri punti di presenza cloud fino a quando non lo riporti online."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "Alta disponibilità (PoPs)",
|
||||
"description": "Puoi anche allegare più nodi al tuo account per ridondanza e prestazioni migliori."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Miglioramenti futuri",
|
||||
"description": "Stiamo pianificando di aggiungere più strumenti di analisi, allerta e gestione per rendere la tua distribuzione ancora più robusta."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Scopri di più sull'opzione Managed Self-Hosted nella nostra",
|
||||
"documentation": "documentazione"
|
||||
},
|
||||
"convertButton": "Converti questo nodo in auto-ospitato gestito"
|
||||
},
|
||||
"internationaldomaindetected": "Dominio Internazionale Rilevato",
|
||||
"willbestoredas": "Verrà conservato come:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC provider",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Intestazioni Personalizzate",
|
||||
"headersValidationError": "Le intestazioni devono essere nel formato: Intestazione-Nome: valore.",
|
||||
"domainPickerProvidedDomain": "Dominio Fornito",
|
||||
"domainPickerFreeProvidedDomain": "Dominio Fornito Gratuito",
|
||||
"domainPickerVerified": "Verificato",
|
||||
"domainPickerUnverified": "Non Verificato",
|
||||
"domainPickerInvalidSubdomainStructure": "Questo sottodominio contiene caratteri o struttura non validi. Sarà sanificato automaticamente quando si salva.",
|
||||
"domainPickerError": "Errore",
|
||||
"domainPickerErrorLoadDomains": "Impossibile caricare i domini dell'organizzazione",
|
||||
"domainPickerErrorCheckAvailability": "Impossibile verificare la disponibilità del dominio",
|
||||
"domainPickerInvalidSubdomain": "Sottodominio non valido",
|
||||
"domainPickerInvalidSubdomainRemoved": "L'input \"{sub}\" è stato rimosso perché non è valido.",
|
||||
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" non può essere reso valido per {domain}.",
|
||||
"domainPickerSubdomainSanitized": "Sottodominio igienizzato",
|
||||
"domainPickerSubdomainCorrected": "\"{sub}\" è stato corretto in \"{sanitized}\"",
|
||||
"resourceAddEntrypointsEditFile": "Modifica file: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Modifica file: docker-compose.yml"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "사용자를 역할에 추가하는 동안 오류가 발생했습니다.",
|
||||
"userSaved": "사용자 저장됨",
|
||||
"userSavedDescription": "사용자가 업데이트되었습니다.",
|
||||
"autoProvisioned": "자동 프로비저닝됨",
|
||||
"autoProvisionedDescription": "이 사용자가 ID 공급자에 의해 자동으로 관리될 수 있도록 허용합니다",
|
||||
"accessControlsDescription": "이 사용자가 조직에서 접근하고 수행할 수 있는 작업을 관리하세요",
|
||||
"accessControlsSubmit": "접근 제어 저장",
|
||||
"roles": "역할",
|
||||
|
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "잘못된 IP 주소 형식",
|
||||
"ipAddressErrorInvalidOctet": "유효하지 않은 IP 주소 옥텟",
|
||||
"path": "경로",
|
||||
"matchPath": "경로 맞춤",
|
||||
"ipAddressRange": "IP 범위",
|
||||
"rulesErrorFetch": "규칙을 가져오는 데 실패했습니다.",
|
||||
"rulesErrorFetchDescription": "규칙을 가져오는 중 오류가 발생했습니다",
|
||||
|
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "연결됨",
|
||||
"idpErrorConnectingTo": "{name}에 연결하는 데 문제가 발생했습니다. 관리자에게 문의하십시오.",
|
||||
"idpErrorNotFound": "IdP를 찾을 수 없습니다.",
|
||||
"idpGoogleAlt": "구글",
|
||||
"idpAzureAlt": "애저",
|
||||
"inviteInvalid": "유효하지 않은 초대",
|
||||
"inviteInvalidDescription": "초대 링크가 유효하지 않습니다.",
|
||||
"inviteErrorWrongUser": "이 초대는 이 사용자에게 해당되지 않습니다",
|
||||
|
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "전문 에디션이 필요합니다.",
|
||||
"licenseTierProfessionalRequiredDescription": "이 기능은 Professional Edition에서만 사용할 수 있습니다.",
|
||||
"actionGetOrg": "조직 가져오기",
|
||||
"updateOrgUser": "조직 사용자 업데이트",
|
||||
"createOrgUser": "조직 사용자 생성",
|
||||
"actionUpdateOrg": "조직 업데이트",
|
||||
"actionUpdateUser": "사용자 업데이트",
|
||||
"actionGetUser": "사용자 조회",
|
||||
|
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "사이트 삭제",
|
||||
"actionGetSite": "사이트 가져오기",
|
||||
"actionListSites": "사이트 목록",
|
||||
"actionApplyBlueprint": "청사진 적용",
|
||||
"setupToken": "설정 토큰",
|
||||
"setupTokenDescription": "서버 콘솔에서 설정 토큰 입력.",
|
||||
"setupTokenRequired": "설정 토큰이 필요합니다",
|
||||
|
|
@ -1059,6 +1067,7 @@
|
|||
"actionGetSiteResource": "사이트 리소스 가져오기",
|
||||
"actionListSiteResources": "사이트 리소스 목록",
|
||||
"actionUpdateSiteResource": "사이트 리소스 업데이트",
|
||||
"actionListInvitations": "초대 목록",
|
||||
"noneSelected": "선택된 항목 없음",
|
||||
"orgNotFound2": "조직이 없습니다.",
|
||||
"searchProgress": "검색...",
|
||||
|
|
@ -1132,8 +1141,8 @@
|
|||
"sidebarLicense": "라이선스",
|
||||
"sidebarClients": "클라이언트 (Beta)",
|
||||
"sidebarDomains": "도메인",
|
||||
"enableDockerSocket": "Docker 소켓 활성화",
|
||||
"enableDockerSocketDescription": "컨테이너 정보를 채우기 위해 Docker 소켓 검색을 활성화합니다. 소켓 경로는 Newt에 제공되어야 합니다.",
|
||||
"enableDockerSocket": "Docker 청사진 활성화",
|
||||
"enableDockerSocketDescription": "블루프린트 레이블을 위한 Docker 소켓 레이블 수집을 활성화합니다. 소켓 경로는 Newt에 제공되어야 합니다.",
|
||||
"enableDockerSocketLink": "자세히 알아보기",
|
||||
"viewDockerContainers": "도커 컨테이너 보기",
|
||||
"containersIn": "{siteName}의 컨테이너",
|
||||
|
|
@ -1233,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "업데이트 가능",
|
||||
"newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
|
||||
"domainPickerEnterDomain": "도메인",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, 또는 그냥 myapp",
|
||||
"domainPickerPlaceholder": "myapp.example.com",
|
||||
"domainPickerDescription": "리소스의 전체 도메인을 입력하여 사용 가능한 옵션을 확인하십시오.",
|
||||
"domainPickerDescriptionSaas": "전체 도메인, 서브도메인 또는 이름을 입력하여 사용 가능한 옵션을 확인하십시오.",
|
||||
"domainPickerTabAll": "모두",
|
||||
|
|
@ -1391,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "프로토콜",
|
||||
"editInternalResourceDialogSitePort": "사이트 포트",
|
||||
"editInternalResourceDialogTargetConfiguration": "대상 구성",
|
||||
"editInternalResourceDialogDestinationIP": "대상 IP",
|
||||
"editInternalResourceDialogDestinationPort": "대상 IP의 포트",
|
||||
"editInternalResourceDialogCancel": "취소",
|
||||
"editInternalResourceDialogSaveResource": "리소스 저장",
|
||||
"editInternalResourceDialogSuccess": "성공",
|
||||
|
|
@ -1423,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "사이트 포트",
|
||||
"createInternalResourceDialogSitePortDescription": "사이트에 연결되었을 때 리소스에 접근하기 위해 이 포트를 사용합니다.",
|
||||
"createInternalResourceDialogTargetConfiguration": "대상 설정",
|
||||
"createInternalResourceDialogDestinationIP": "대상 IP",
|
||||
"createInternalResourceDialogDestinationIPDescription": "사이트 네트워크의 자원 IP 주소입니다.",
|
||||
"createInternalResourceDialogDestinationPort": "대상 포트",
|
||||
"createInternalResourceDialogDestinationIPDescription": "사이트 네트워크의 자원 IP 또는 호스트 네임 주소입니다.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "대상 IP에서 리소스에 접근할 수 있는 포트입니다.",
|
||||
"createInternalResourceDialogCancel": "취소",
|
||||
"createInternalResourceDialogCreateResource": "리소스 생성",
|
||||
|
|
@ -1458,6 +1463,61 @@
|
|||
"autoLoginError": "자동 로그인 오류",
|
||||
"autoLoginErrorNoRedirectUrl": "ID 공급자로부터 리디렉션 URL을 받지 못했습니다.",
|
||||
"autoLoginErrorGeneratingUrl": "인증 URL 생성 실패.",
|
||||
"managedSelfHosted": {
|
||||
"title": "관리 자체 호스팅",
|
||||
"description": "더 신뢰할 수 있고 낮은 유지보수의 자체 호스팅 팡골린 서버, 추가 기능 포함",
|
||||
"introTitle": "관리 자체 호스팅 팡골린",
|
||||
"introDescription": "는 자신의 데이터를 프라이빗하고 자체 호스팅을 유지하면서 더 간단하고 추가적인 신뢰성을 원하는 사람들을 위한 배포 옵션입니다.",
|
||||
"introDetail": "이 옵션을 사용하면 여전히 자신의 팡골린 노드를 운영하고 - 터널, SSL 종료 및 트래픽 모두 서버에 유지됩니다. 차이점은 관리 및 모니터링이 클라우드 대시보드를 통해 처리되어 여러 혜택을 제공합니다.",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "더 간단한 운영",
|
||||
"description": "자체 메일 서버를 운영하거나 복잡한 경고를 설정할 필요가 없습니다. 기본적으로 상태 점검 및 다운타임 경고를 받을 수 있습니다."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "자동 업데이트",
|
||||
"description": "클라우드 대시보드는 빠르게 발전하므로 새로운 기능과 버그 수정 사항을 수동으로 새로운 컨테이너를 가져오지 않고도 받을 수 있습니다."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "유지보수 감소",
|
||||
"description": "데이터베이스 마이그레이션, 백업 또는 추가 인프라를 관리할 필요가 없습니다. 저희가 클라우드에서 처리합니다."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "클라우드 장애 조치",
|
||||
"description": "노드가 다운되면 터널이 클라우드의 프레즌스 포인트로 임시 전환되어 노드를 다시 온라인으로 가져올 때까지 유지됩니다."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "고가용성 (PoPs)",
|
||||
"description": "계정에 여러 노드를 연결하여 이중성과 성능을 향상시킬 수 있습니다."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "향후 개선",
|
||||
"description": "배포를 더욱 견고하게 만들기 위해 더 많은 분석, 경고, 및 관리 도구를 추가할 계획입니다."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "관리 자체 호스팅 옵션에 대해 더 알아보세요",
|
||||
"documentation": "문서"
|
||||
},
|
||||
"convertButton": "이 노드를 관리 자체 호스팅으로 변환"
|
||||
},
|
||||
"internationaldomaindetected": "국제 도메인 감지됨",
|
||||
"willbestoredas": "다음과 같이 저장됩니다."
|
||||
"willbestoredas": "다음으로 저장됩니다:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC 공급자",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC 공급자",
|
||||
"customHeaders": "사용자 정의 헤더",
|
||||
"headersValidationError": "헤더는 형식이어야 합니다: 헤더명: 값.",
|
||||
"domainPickerProvidedDomain": "제공된 도메인",
|
||||
"domainPickerFreeProvidedDomain": "무료 제공된 도메인",
|
||||
"domainPickerVerified": "검증됨",
|
||||
"domainPickerUnverified": "검증되지 않음",
|
||||
"domainPickerInvalidSubdomainStructure": "이 하위 도메인은 잘못된 문자 또는 구조를 포함하고 있습니다. 저장 시 자동으로 정리됩니다.",
|
||||
"domainPickerError": "오류",
|
||||
"domainPickerErrorLoadDomains": "조직 도메인 로드 실패",
|
||||
"domainPickerErrorCheckAvailability": "도메인 가용성 확인 실패",
|
||||
"domainPickerInvalidSubdomain": "잘못된 하위 도메인",
|
||||
"domainPickerInvalidSubdomainRemoved": "입력 \"{sub}\"이(가) 유효하지 않으므로 제거되었습니다.",
|
||||
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\"을(를) {domain}에 대해 유효하게 만들 수 없습니다.",
|
||||
"domainPickerSubdomainSanitized": "하위 도메인 정리됨",
|
||||
"domainPickerSubdomainCorrected": "\"{sub}\"이(가) \"{sanitized}\"로 수정되었습니다",
|
||||
"resourceAddEntrypointsEditFile": "파일 편집: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "파일 편집: docker-compose.yml"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "Det oppstod en feil under tilordning av brukeren til rollen.",
|
||||
"userSaved": "Bruker lagret",
|
||||
"userSavedDescription": "Brukeren har blitt oppdatert.",
|
||||
"autoProvisioned": "Auto avlyst",
|
||||
"autoProvisionedDescription": "Tillat denne brukeren å bli automatisk administrert av en identitetsleverandør",
|
||||
"accessControlsDescription": "Administrer hva denne brukeren kan få tilgang til og gjøre i organisasjonen",
|
||||
"accessControlsSubmit": "Lagre tilgangskontroller",
|
||||
"roles": "Roller",
|
||||
|
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Ugyldig IP-adresseformat",
|
||||
"ipAddressErrorInvalidOctet": "Ugyldig IP-adresse-oktet",
|
||||
"path": "Sti",
|
||||
"matchPath": "Match sti",
|
||||
"ipAddressRange": "IP-område",
|
||||
"rulesErrorFetch": "Klarte ikke å hente regler",
|
||||
"rulesErrorFetchDescription": "Det oppsto en feil under henting av regler",
|
||||
|
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Tilkoblet",
|
||||
"idpErrorConnectingTo": "Det oppstod et problem med å koble til {name}. Vennligst kontakt din administrator.",
|
||||
"idpErrorNotFound": "IdP ikke funnet",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Ugyldig invitasjon",
|
||||
"inviteInvalidDescription": "Invitasjonslenken er ugyldig.",
|
||||
"inviteErrorWrongUser": "Invitasjonen er ikke for denne brukeren",
|
||||
|
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Profesjonell utgave påkrevd",
|
||||
"licenseTierProfessionalRequiredDescription": "Denne funksjonen er kun tilgjengelig i den profesjonelle utgaven.",
|
||||
"actionGetOrg": "Hent organisasjon",
|
||||
"updateOrgUser": "Oppdater org.bruker",
|
||||
"createOrgUser": "Opprett Org bruker",
|
||||
"actionUpdateOrg": "Oppdater organisasjon",
|
||||
"actionUpdateUser": "Oppdater bruker",
|
||||
"actionGetUser": "Hent bruker",
|
||||
|
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Slett område",
|
||||
"actionGetSite": "Hent område",
|
||||
"actionListSites": "List opp områder",
|
||||
"actionApplyBlueprint": "Bruk blåkopi",
|
||||
"setupToken": "Oppsetttoken",
|
||||
"setupTokenDescription": "Skriv inn oppsetttoken fra serverkonsollen.",
|
||||
"setupTokenRequired": "Oppsetttoken er nødvendig",
|
||||
|
|
@ -1059,6 +1067,7 @@
|
|||
"actionGetSiteResource": "Hent Stedsressurs",
|
||||
"actionListSiteResources": "List opp Stedsressurser",
|
||||
"actionUpdateSiteResource": "Oppdater Stedsressurs",
|
||||
"actionListInvitations": "Liste invitasjoner",
|
||||
"noneSelected": "Ingen valgt",
|
||||
"orgNotFound2": "Ingen organisasjoner funnet.",
|
||||
"searchProgress": "Søker...",
|
||||
|
|
@ -1132,8 +1141,8 @@
|
|||
"sidebarLicense": "Lisens",
|
||||
"sidebarClients": "Klienter (Beta)",
|
||||
"sidebarDomains": "Domener",
|
||||
"enableDockerSocket": "Aktiver Docker Socket",
|
||||
"enableDockerSocketDescription": "Aktiver Docker Socket-oppdagelse for å fylle ut containerinformasjon. Socket-stien må oppgis til Newt.",
|
||||
"enableDockerSocket": "Aktiver Docker blåkopi",
|
||||
"enableDockerSocketDescription": "Aktiver skraping av Docker Socket for blueprint Etiketter. Socket bane må brukes for nye.",
|
||||
"enableDockerSocketLink": "Lær mer",
|
||||
"viewDockerContainers": "Vis Docker-containere",
|
||||
"containersIn": "Containere i {siteName}",
|
||||
|
|
@ -1233,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Oppdatering tilgjengelig",
|
||||
"newtUpdateAvailableInfo": "En ny versjon av Newt er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.",
|
||||
"domainPickerEnterDomain": "Domene",
|
||||
"domainPickerPlaceholder": "minapp.eksempel.com, api.v1.mittdomene.com, eller bare minapp",
|
||||
"domainPickerPlaceholder": "minapp.eksempel.no",
|
||||
"domainPickerDescription": "Skriv inn hele domenet til ressursen for å se tilgjengelige alternativer.",
|
||||
"domainPickerDescriptionSaas": "Skriv inn et fullt domene, underdomene eller bare et navn for å se tilgjengelige alternativer",
|
||||
"domainPickerTabAll": "Alle",
|
||||
|
|
@ -1391,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protokoll",
|
||||
"editInternalResourceDialogSitePort": "Områdeport",
|
||||
"editInternalResourceDialogTargetConfiguration": "Målkonfigurasjon",
|
||||
"editInternalResourceDialogDestinationIP": "Destinasjons-IP",
|
||||
"editInternalResourceDialogDestinationPort": "Destinasjonsport",
|
||||
"editInternalResourceDialogCancel": "Avbryt",
|
||||
"editInternalResourceDialogSaveResource": "Lagre ressurs",
|
||||
"editInternalResourceDialogSuccess": "Suksess",
|
||||
|
|
@ -1423,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Områdeport",
|
||||
"createInternalResourceDialogSitePortDescription": "Bruk denne porten for å få tilgang til ressursen på området når du er tilkoblet med en klient.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Målkonfigurasjon",
|
||||
"createInternalResourceDialogDestinationIP": "Destinasjons-IP",
|
||||
"createInternalResourceDialogDestinationIPDescription": "IP-adressen til ressursen på områdets nettverk.",
|
||||
"createInternalResourceDialogDestinationPort": "Destinasjonsport",
|
||||
"createInternalResourceDialogDestinationIPDescription": "IP eller vertsnavn til ressursen på nettstedets nettverk.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "Porten på destinasjons-IP-en der ressursen kan nås.",
|
||||
"createInternalResourceDialogCancel": "Avbryt",
|
||||
"createInternalResourceDialogCreateResource": "Opprett ressurs",
|
||||
|
|
@ -1457,5 +1462,62 @@
|
|||
"autoLoginRedirecting": "Omdirigerer til innlogging...",
|
||||
"autoLoginError": "Feil ved automatisk innlogging",
|
||||
"autoLoginErrorNoRedirectUrl": "Ingen omdirigerings-URL mottatt fra identitetsleverandøren.",
|
||||
"autoLoginErrorGeneratingUrl": "Kunne ikke generere autentiserings-URL."
|
||||
"autoLoginErrorGeneratingUrl": "Kunne ikke generere autentiserings-URL.",
|
||||
"managedSelfHosted": {
|
||||
"title": "Administrert selv-hostet",
|
||||
"description": "Sikre og lavvedlikeholdsservere, selvbetjente Pangolin med ekstra klokker, og understell",
|
||||
"introTitle": "Administrert Self-Hosted Pangolin",
|
||||
"introDescription": "er et alternativ for bruk utviklet for personer som ønsker enkel og ekstra pålitelighet mens de fortsatt holder sine data privat og selvdrevne.",
|
||||
"introDetail": "Med dette valget kjører du fortsatt din egen Pangolin-node - tunneler, SSL-terminering og trafikken ligger på serveren din. Forskjellen er at behandling og overvåking håndteres gjennom vårt skydashbord, som låser opp en rekke fordeler:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Enklere operasjoner",
|
||||
"description": "Ingen grunn til å kjøre din egen e-postserver eller sette opp kompleks varsling. Du vil få helsesjekk og nedetid varsler ut av boksen."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Automatiske oppdateringer",
|
||||
"description": "Cloud dashbordet utvikler seg raskt, så du får nye funksjoner og feilrettinger uten at du trenger å trekke nye beholdere manuelt hver gang."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Mindre vedlikehold",
|
||||
"description": "Ingen databasestyrer, sikkerhetskopier eller ekstra infrastruktur for å forvalte. Vi håndterer det i skyen."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Sky feilslått",
|
||||
"description": "Hvis EK-gruppen din går ned, kan tunnlene midlertidig mislykkes i å nå våre sky-punkter til du tar den tilbake på nett."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "Høy tilgjengelighet (PoPs)",
|
||||
"description": "Du kan også legge ved flere noder til kontoen din for redundans og bedre ytelse."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Fremtidige forbedringer",
|
||||
"description": "Vi planlegger å legge inn mer analyser, varsle og styringsverktøy for å gjøre din distribusjon enda mer robust."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Lær mer om Managed Self-Hosted alternativet i vår",
|
||||
"documentation": "dokumentasjon"
|
||||
},
|
||||
"convertButton": "Konverter denne noden til manuelt bruk"
|
||||
},
|
||||
"internationaldomaindetected": "Internasjonalt domene oppdaget",
|
||||
"willbestoredas": "Vil bli lagret som:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC leverandør",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Egendefinerte topptekster",
|
||||
"headersValidationError": "Topptekst må være i formatet: header-navn: verdi.",
|
||||
"domainPickerProvidedDomain": "Gitt domene",
|
||||
"domainPickerFreeProvidedDomain": "Gratis oppgitt domene",
|
||||
"domainPickerVerified": "Bekreftet",
|
||||
"domainPickerUnverified": "Uverifisert",
|
||||
"domainPickerInvalidSubdomainStructure": "Dette underdomenet inneholder ugyldige tegn eller struktur. Det vil automatisk bli utsatt når du lagrer.",
|
||||
"domainPickerError": "Feil",
|
||||
"domainPickerErrorLoadDomains": "Kan ikke laste organisasjonens domener",
|
||||
"domainPickerErrorCheckAvailability": "Kunne ikke kontrollere domenetilgjengelighet",
|
||||
"domainPickerInvalidSubdomain": "Ugyldig underdomene",
|
||||
"domainPickerInvalidSubdomainRemoved": "Inndata \"{sub}\" ble fjernet fordi det ikke er gyldig.",
|
||||
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" kunne ikke gjøres gyldig for {domain}.",
|
||||
"domainPickerSubdomainSanitized": "Underdomenet som ble sanivert",
|
||||
"domainPickerSubdomainCorrected": "\"{sub}\" var korrigert til \"{sanitized}\"",
|
||||
"resourceAddEntrypointsEditFile": "Rediger fil: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Rediger fil: docker-compose.yml"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,12 +38,12 @@
|
|||
"name": "naam",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"site": "Website",
|
||||
"dataIn": "Gegevens in",
|
||||
"dataOut": "Data Uit",
|
||||
"site": "Referentie",
|
||||
"dataIn": "Dataverbruik inkomend",
|
||||
"dataOut": "Dataverbruik uitgaand",
|
||||
"connectionType": "Type verbinding",
|
||||
"tunnelType": "Tunnel type",
|
||||
"local": "lokaal",
|
||||
"local": "Lokaal",
|
||||
"edit": "Bewerken",
|
||||
"siteConfirmDelete": "Verwijderen van site bevestigen",
|
||||
"siteDelete": "Site verwijderen",
|
||||
|
|
@ -55,7 +55,7 @@
|
|||
"siteCreate": "Site maken",
|
||||
"siteCreateDescription2": "Volg de onderstaande stappen om een nieuwe site aan te maken en te verbinden",
|
||||
"siteCreateDescription": "Maak een nieuwe site aan om verbinding te maken met uw bronnen",
|
||||
"close": "Afsluiten",
|
||||
"close": "Sluiten",
|
||||
"siteErrorCreate": "Fout bij maken site",
|
||||
"siteErrorCreateKeyPair": "Key pair of site standaard niet gevonden",
|
||||
"siteErrorCreateDefaults": "Standaardinstellingen niet gevonden",
|
||||
|
|
@ -90,7 +90,7 @@
|
|||
"siteGeneralDescription": "Algemene instellingen voor deze site configureren",
|
||||
"siteSettingDescription": "Configureer de instellingen op uw site",
|
||||
"siteSetting": "{siteName} instellingen",
|
||||
"siteNewtTunnel": "Nieuwstunnel (Aanbevolen)",
|
||||
"siteNewtTunnel": "Newttunnel (Aanbevolen)",
|
||||
"siteNewtTunnelDescription": "Gemakkelijkste manier om een ingangspunt in uw netwerk te maken. Geen extra opzet.",
|
||||
"siteWg": "Basis WireGuard",
|
||||
"siteWgDescription": "Gebruik een WireGuard client om een tunnel te bouwen. Handmatige NAT installatie vereist.",
|
||||
|
|
@ -104,7 +104,7 @@
|
|||
"siteCredentialsSave": "Uw referenties opslaan",
|
||||
"siteCredentialsSaveDescription": "Je kunt dit slechts één keer zien. Kopieer het naar een beveiligde plek.",
|
||||
"siteInfo": "Site informatie",
|
||||
"status": "status",
|
||||
"status": "Status",
|
||||
"shareTitle": "Beheer deellinks",
|
||||
"shareDescription": "Maak deelbare links aan om tijdelijke of permanente toegang tot uw bronnen te verlenen",
|
||||
"shareSearch": "Zoek share links...",
|
||||
|
|
@ -146,19 +146,19 @@
|
|||
"never": "Nooit",
|
||||
"shareErrorSelectResource": "Selecteer een bron",
|
||||
"resourceTitle": "Bronnen beheren",
|
||||
"resourceDescription": "Veilige proxy's voor uw privé applicaties maken",
|
||||
"resourceDescription": "Veilige proxy's voor uw privé applicaties aanmaken",
|
||||
"resourcesSearch": "Zoek bronnen...",
|
||||
"resourceAdd": "Bron toevoegen",
|
||||
"resourceErrorDelte": "Fout bij verwijderen document",
|
||||
"authentication": "Authenticatie",
|
||||
"protected": "Beschermd",
|
||||
"notProtected": "Niet beschermd",
|
||||
"protected": "Beveiligd",
|
||||
"notProtected": "Niet beveiligd",
|
||||
"resourceMessageRemove": "Eenmaal verwijderd, zal het bestand niet langer toegankelijk zijn. Alle doelen die gekoppeld zijn aan het hulpbron, zullen ook verwijderd worden.",
|
||||
"resourceMessageConfirm": "Om te bevestigen, typ de naam van de bron hieronder.",
|
||||
"resourceQuestionRemove": "Weet u zeker dat u de resource {selectedResource} uit de organisatie wilt verwijderen?",
|
||||
"resourceHTTP": "HTTPS bron",
|
||||
"resourceHTTPDescription": "Proxy verzoeken aan uw app via HTTPS via een subdomein of basisdomein.",
|
||||
"resourceRaw": "Ruwe TCP/UDP bron",
|
||||
"resourceRaw": "TCP/UDP bron",
|
||||
"resourceRawDescription": "Proxy verzoeken naar je app via TCP/UDP met behulp van een poortnummer.",
|
||||
"resourceCreate": "Bron maken",
|
||||
"resourceCreateDescription": "Volg de onderstaande stappen om een nieuwe bron te maken",
|
||||
|
|
@ -183,7 +183,7 @@
|
|||
"protocolSelect": "Selecteer een protocol",
|
||||
"resourcePortNumber": "Nummer van poort",
|
||||
"resourcePortNumberDescription": "Het externe poortnummer naar proxyverzoeken.",
|
||||
"cancel": "annuleren",
|
||||
"cancel": "Annuleren",
|
||||
"resourceConfig": "Configuratie tekstbouwstenen",
|
||||
"resourceConfigDescription": "Kopieer en plak deze configuratie-snippets om je TCP/UDP-bron in te stellen",
|
||||
"resourceAddEntrypoints": "Traefik: Entrypoints toevoegen",
|
||||
|
|
@ -212,7 +212,7 @@
|
|||
"saveGeneralSettings": "Algemene instellingen opslaan",
|
||||
"saveSettings": "Instellingen opslaan",
|
||||
"orgDangerZone": "Gevaarlijke zone",
|
||||
"orgDangerZoneDescription": "Als u deze instantie verwijdert, is er geen weg terug. Wees het alstublieft zeker.",
|
||||
"orgDangerZoneDescription": "Deze instantie verwijderen is onomkeerbaar. Bevestig alstublieft dat u wilt doorgaan.",
|
||||
"orgDelete": "Verwijder organisatie",
|
||||
"orgDeleteConfirm": "Bevestig Verwijderen Organisatie",
|
||||
"orgMessageRemove": "Deze actie is onomkeerbaar en zal alle bijbehorende gegevens verwijderen.",
|
||||
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "Er is een fout opgetreden tijdens het toevoegen van de rol.",
|
||||
"userSaved": "Gebruiker opgeslagen",
|
||||
"userSavedDescription": "De gebruiker is bijgewerkt.",
|
||||
"autoProvisioned": "Automatisch bevestigen",
|
||||
"autoProvisionedDescription": "Toestaan dat deze gebruiker automatisch wordt beheerd door een identiteitsprovider",
|
||||
"accessControlsDescription": "Beheer wat deze gebruiker toegang heeft tot en doet in de organisatie",
|
||||
"accessControlsSubmit": "Bewaar Toegangsbesturing",
|
||||
"roles": "Rollen",
|
||||
|
|
@ -499,8 +501,8 @@
|
|||
"targetStickySessionsDescription": "Behoud verbindingen op hetzelfde backend doel voor hun hele sessie.",
|
||||
"methodSelect": "Selecteer methode",
|
||||
"targetSubmit": "Doelwit toevoegen",
|
||||
"targetNoOne": "Geen doelwitten. Voeg een doel toe via het formulier.",
|
||||
"targetNoOneDescription": "Het toevoegen van meer dan één doel hierboven zal de load balancering mogelijk maken.",
|
||||
"targetNoOne": "Geen doel toegevoegd. Voeg deze toe via dit formulier.",
|
||||
"targetNoOneDescription": "Het toevoegen van meer dan één doel hierboven zal load balancering mogelijk maken.",
|
||||
"targetsSubmit": "Doelstellingen opslaan",
|
||||
"proxyAdditional": "Extra Proxy-instellingen",
|
||||
"proxyAdditionalDescription": "Configureer hoe de proxy-instellingen van uw bron worden afgehandeld",
|
||||
|
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Ongeldig IP-adresformaat",
|
||||
"ipAddressErrorInvalidOctet": "Ongeldige IP adres octet",
|
||||
"path": "Pad",
|
||||
"matchPath": "Overeenkomend pad",
|
||||
"ipAddressRange": "IP Bereik",
|
||||
"rulesErrorFetch": "Regels ophalen mislukt",
|
||||
"rulesErrorFetchDescription": "Er is een fout opgetreden bij het ophalen van de regels",
|
||||
|
|
@ -595,7 +598,7 @@
|
|||
"newtId": "Newt-ID",
|
||||
"newtSecretKey": "Nieuwe geheime sleutel",
|
||||
"architecture": "Architectuur",
|
||||
"sites": "Werkruimtes",
|
||||
"sites": "Verbindingen",
|
||||
"siteWgAnyClients": "Gebruik een willekeurige WireGuard client om verbinding te maken. Je moet je interne bronnen aanspreken met behulp van de peer IP.",
|
||||
"siteWgCompatibleAllClients": "Compatibel met alle WireGuard clients",
|
||||
"siteWgManualConfigurationRequired": "Handmatige configuratie vereist",
|
||||
|
|
@ -726,7 +729,7 @@
|
|||
"idpMessageConfirm": "Om dit te bevestigen, typt u de naam van onderstaande identiteitsprovider.",
|
||||
"idpConfirmDelete": "Bevestig verwijderen Identity Provider",
|
||||
"idpDelete": "Identity Provider verwijderen",
|
||||
"idp": "Identiteit aanbieders",
|
||||
"idp": "Identiteitsaanbieders",
|
||||
"idpSearch": "Identiteitsaanbieders zoeken...",
|
||||
"idpAdd": "Identity Provider toevoegen",
|
||||
"idpClientIdRequired": "Client-ID is vereist.",
|
||||
|
|
@ -798,7 +801,7 @@
|
|||
"defaultMappingsOrgDescription": "Deze expressie moet de org-ID teruggeven of waar om de gebruiker toegang te geven tot de organisatie.",
|
||||
"defaultMappingsSubmit": "Standaard toewijzingen opslaan",
|
||||
"orgPoliciesEdit": "Organisatie beleid bewerken",
|
||||
"org": "Rekening",
|
||||
"org": "Organisatie",
|
||||
"orgSelect": "Selecteer organisatie",
|
||||
"orgSearch": "Zoek in org",
|
||||
"orgNotFound": "Geen org gevonden.",
|
||||
|
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Verbonden",
|
||||
"idpErrorConnectingTo": "Er was een probleem bij het verbinden met {name}. Neem contact op met uw beheerder.",
|
||||
"idpErrorNotFound": "IdP niet gevonden",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Ongeldige uitnodiging",
|
||||
"inviteInvalidDescription": "Uitnodigingslink is ongeldig.",
|
||||
"inviteErrorWrongUser": "Uitnodiging is niet voor deze gebruiker",
|
||||
|
|
@ -971,10 +976,10 @@
|
|||
"supportKeyEnterDescription": "Ontmoet je eigen huisdier Pangolin!",
|
||||
"githubUsername": "GitHub-gebruikersnaam",
|
||||
"supportKeyInput": "Supporter Sleutel",
|
||||
"supportKeyBuy": "Koop Supportersleutel",
|
||||
"supportKeyBuy": "Koop supportersleutel",
|
||||
"logoutError": "Fout bij uitloggen",
|
||||
"signingAs": "Ingelogd als",
|
||||
"serverAdmin": "Server Beheerder",
|
||||
"serverAdmin": "Server beheer",
|
||||
"managedSelfhosted": "Beheerde Self-Hosted",
|
||||
"otpEnable": "Twee-factor inschakelen",
|
||||
"otpDisable": "Tweestapsverificatie uitschakelen",
|
||||
|
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Professionele editie vereist",
|
||||
"licenseTierProfessionalRequiredDescription": "Deze functie is alleen beschikbaar in de Professional Edition.",
|
||||
"actionGetOrg": "Krijg Organisatie",
|
||||
"updateOrgUser": "Org gebruiker bijwerken",
|
||||
"createOrgUser": "Org gebruiker aanmaken",
|
||||
"actionUpdateOrg": "Organisatie bijwerken",
|
||||
"actionUpdateUser": "Gebruiker bijwerken",
|
||||
"actionGetUser": "Gebruiker ophalen",
|
||||
|
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Site verwijderen",
|
||||
"actionGetSite": "Site ophalen",
|
||||
"actionListSites": "Sites weergeven",
|
||||
"actionApplyBlueprint": "Blauwdruk toepassen",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenDescription": "Voer het setup-token in vanaf de serverconsole.",
|
||||
"setupTokenRequired": "Setup-token is vereist",
|
||||
|
|
@ -1059,6 +1067,7 @@
|
|||
"actionGetSiteResource": "Bron van site ophalen",
|
||||
"actionListSiteResources": "Bronnen van site weergeven",
|
||||
"actionUpdateSiteResource": "Document bijwerken van site",
|
||||
"actionListInvitations": "Toon uitnodigingen",
|
||||
"noneSelected": "Niet geselecteerd",
|
||||
"orgNotFound2": "Geen organisaties gevonden.",
|
||||
"searchProgress": "Zoeken...",
|
||||
|
|
@ -1119,7 +1128,7 @@
|
|||
"sidebarOverview": "Overzicht.",
|
||||
"sidebarHome": "Startpagina",
|
||||
"sidebarSites": "Werkruimtes",
|
||||
"sidebarResources": "Hulpmiddelen",
|
||||
"sidebarResources": "Bronnen",
|
||||
"sidebarAccessControl": "Toegangs controle",
|
||||
"sidebarUsers": "Gebruikers",
|
||||
"sidebarInvitations": "Uitnodigingen",
|
||||
|
|
@ -1132,13 +1141,13 @@
|
|||
"sidebarLicense": "Licentie",
|
||||
"sidebarClients": "Clients (Bèta)",
|
||||
"sidebarDomains": "Domeinen",
|
||||
"enableDockerSocket": "Docker Socket inschakelen",
|
||||
"enableDockerSocketDescription": "Docker Socket-ontdekking inschakelen voor het invullen van containerinformatie. Socket-pad moet aan Newt worden verstrekt.",
|
||||
"enableDockerSocket": "Schakel Docker Blauwdruk in",
|
||||
"enableDockerSocketDescription": "Schakel Docker Socket label in voor blauwdruk labels. Pad naar Nieuw.",
|
||||
"enableDockerSocketLink": "Meer informatie",
|
||||
"viewDockerContainers": "Bekijk Docker containers",
|
||||
"containersIn": "Containers in {siteName}",
|
||||
"selectContainerDescription": "Selecteer een container om als hostnaam voor dit doel te gebruiken. Klik op een poort om een poort te gebruiken.",
|
||||
"containerName": "naam",
|
||||
"containerName": "Naam",
|
||||
"containerImage": "Afbeelding",
|
||||
"containerState": "Provincie",
|
||||
"containerNetworks": "Netwerken",
|
||||
|
|
@ -1233,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Update beschikbaar",
|
||||
"newtUpdateAvailableInfo": "Er is een nieuwe versie van Newt beschikbaar. Update naar de nieuwste versie voor de beste ervaring.",
|
||||
"domainPickerEnterDomain": "Domein",
|
||||
"domainPickerPlaceholder": "mijnapp.voorbeeld.com, api.v1.mijndomein.com, of gewoon mijnapp",
|
||||
"domainPickerPlaceholder": "mijnapp.voorbeeld.nl",
|
||||
"domainPickerDescription": "Voer de volledige domein van de bron in om beschikbare opties te zien.",
|
||||
"domainPickerDescriptionSaas": "Voer een volledig domein, subdomein of gewoon een naam in om beschikbare opties te zien",
|
||||
"domainPickerTabAll": "Alles",
|
||||
|
|
@ -1340,7 +1349,7 @@
|
|||
"olmId": "Olm ID",
|
||||
"olmSecretKey": "Olm Geheime Sleutel",
|
||||
"clientCredentialsSave": "Uw referenties opslaan",
|
||||
"clientCredentialsSaveDescription": "Je kunt dit slechts één keer zien. Kopieer het naar een beveiligde plek.",
|
||||
"clientCredentialsSaveDescription": "Je kunt dit slechts één keer zien. Kopieer deze naar een veilige plek.",
|
||||
"generalSettingsDescription": "Configureer de algemene instellingen voor deze client",
|
||||
"clientUpdated": "Klant bijgewerkt ",
|
||||
"clientUpdatedDescription": "De client is bijgewerkt.",
|
||||
|
|
@ -1391,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protocol",
|
||||
"editInternalResourceDialogSitePort": "Site Poort",
|
||||
"editInternalResourceDialogTargetConfiguration": "Doelconfiguratie",
|
||||
"editInternalResourceDialogDestinationIP": "Bestemming IP",
|
||||
"editInternalResourceDialogDestinationPort": "Bestemmingspoort",
|
||||
"editInternalResourceDialogCancel": "Annuleren",
|
||||
"editInternalResourceDialogSaveResource": "Sla bron op",
|
||||
"editInternalResourceDialogSuccess": "Succes",
|
||||
|
|
@ -1423,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Site Poort",
|
||||
"createInternalResourceDialogSitePortDescription": "Gebruik deze poort om toegang te krijgen tot de bron op de site wanneer verbonden met een client.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Doelconfiguratie",
|
||||
"createInternalResourceDialogDestinationIP": "Bestemming IP",
|
||||
"createInternalResourceDialogDestinationIPDescription": "Het IP-adres van de bron op het netwerk van de site.",
|
||||
"createInternalResourceDialogDestinationPort": "Bestemmingspoort",
|
||||
"createInternalResourceDialogDestinationIPDescription": "Het IP of hostnaam adres van de bron op het netwerk van de site.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "De poort op het bestemmings-IP waar de bron toegankelijk is.",
|
||||
"createInternalResourceDialogCancel": "Annuleren",
|
||||
"createInternalResourceDialogCreateResource": "Bron aanmaken",
|
||||
|
|
@ -1458,6 +1463,61 @@
|
|||
"autoLoginError": "Auto Login Fout",
|
||||
"autoLoginErrorNoRedirectUrl": "Geen redirect URL ontvangen van de identity provider.",
|
||||
"autoLoginErrorGeneratingUrl": "Genereren van authenticatie-URL mislukt.",
|
||||
"managedSelfHosted": {
|
||||
"title": "Beheerde Self-Hosted",
|
||||
"description": "betrouwbaardere en slecht onderhouden Pangolin server met extra klokken en klokkenluiders",
|
||||
"introTitle": "Beheerde zelfgehoste pangolin",
|
||||
"introDescription": "is een implementatieoptie ontworpen voor mensen die eenvoud en extra betrouwbaarheid willen, terwijl hun gegevens privé en zelf georganiseerd blijven.",
|
||||
"introDetail": "Met deze optie beheert u nog steeds uw eigen Pangolin node - uw tunnels, SSL-verbinding en verkeer alles op uw server. Het verschil is dat beheer en monitoring worden behandeld via onze cloud dashboard, wat een aantal voordelen oplevert:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Simpler operaties",
|
||||
"description": "Je hoeft geen eigen mailserver te draaien of complexe waarschuwingen in te stellen. Je krijgt gezondheidscontroles en downtime meldingen uit de box."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Automatische updates",
|
||||
"description": "Het cloud dashboard evolueert snel, zodat u nieuwe functies en bug fixes krijgt zonder elke keer handmatig nieuwe containers te moeten trekken."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Minder onderhoud",
|
||||
"description": "Geen database migratie, back-ups of extra infrastructuur om te beheren. Dat behandelen we in de cloud."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Cloud fout",
|
||||
"description": "Als uw node omlaag gaat, kunnen uw tunnels tijdelijk niet meer naar onze aanwezigheidspunten gaan totdat u hem weer online brengt."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "Hoge beschikbaarheid (PoPs)",
|
||||
"description": "U kunt ook meerdere nodes koppelen aan uw account voor ontslag en betere prestaties."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Toekomstige verbeteringen",
|
||||
"description": "We zijn van plan om meer analytica, waarschuwing en beheerhulpmiddelen toe te voegen om uw implementatie nog steviger te maken."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Meer informatie over de optie voor zelf-verzorging in onze",
|
||||
"documentation": "documentatie"
|
||||
},
|
||||
"convertButton": "Converteer deze node naar Beheerde Zelf-Hosted"
|
||||
},
|
||||
"internationaldomaindetected": "Internationaal Domein Gedetecteerd",
|
||||
"willbestoredas": "Wordt opgeslagen als:"
|
||||
"willbestoredas": "Zal worden opgeslagen als:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC provider",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Aangepaste headers",
|
||||
"headersValidationError": "Headers moeten in het formaat zijn: Header-Naam: waarde.",
|
||||
"domainPickerProvidedDomain": "Opgegeven domein",
|
||||
"domainPickerFreeProvidedDomain": "Gratis verstrekt domein",
|
||||
"domainPickerVerified": "Geverifieerd",
|
||||
"domainPickerUnverified": "Ongeverifieerd",
|
||||
"domainPickerInvalidSubdomainStructure": "Dit subdomein bevat ongeldige tekens of structuur. Het zal automatisch worden gesaneerd wanneer u opslaat.",
|
||||
"domainPickerError": "Foutmelding",
|
||||
"domainPickerErrorLoadDomains": "Fout bij het laden van organisatiedomeinen",
|
||||
"domainPickerErrorCheckAvailability": "Kan domein beschikbaarheid niet controleren",
|
||||
"domainPickerInvalidSubdomain": "Ongeldig subdomein",
|
||||
"domainPickerInvalidSubdomainRemoved": "De invoer \"{sub}\" is verwijderd omdat het niet geldig is.",
|
||||
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" kon niet geldig worden gemaakt voor {domain}.",
|
||||
"domainPickerSubdomainSanitized": "Subdomein gesaniseerd",
|
||||
"domainPickerSubdomainCorrected": "\"{sub}\" was gecorrigeerd op \"{sanitized}\"",
|
||||
"resourceAddEntrypointsEditFile": "Bestand bewerken: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Bestand bewerken: docker-compose.yml"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "Wystąpił błąd podczas dodawania użytkownika do roli.",
|
||||
"userSaved": "Użytkownik zapisany",
|
||||
"userSavedDescription": "Użytkownik został zaktualizowany.",
|
||||
"autoProvisioned": "Przesłane automatycznie",
|
||||
"autoProvisionedDescription": "Pozwól temu użytkownikowi na automatyczne zarządzanie przez dostawcę tożsamości",
|
||||
"accessControlsDescription": "Zarządzaj tym, do czego użytkownik ma dostęp i co może robić w organizacji",
|
||||
"accessControlsSubmit": "Zapisz kontrole dostępu",
|
||||
"roles": "Role",
|
||||
|
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Nieprawidłowy format adresu IP",
|
||||
"ipAddressErrorInvalidOctet": "Nieprawidłowy oktet adresu IP",
|
||||
"path": "Ścieżka",
|
||||
"matchPath": "Ścieżka dopasowania",
|
||||
"ipAddressRange": "Zakres IP",
|
||||
"rulesErrorFetch": "Nie udało się pobrać reguł",
|
||||
"rulesErrorFetchDescription": "Wystąpił błąd podczas pobierania reguł",
|
||||
|
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Połączono",
|
||||
"idpErrorConnectingTo": "Wystąpił problem z połączeniem z {name}. Skontaktuj się z administratorem.",
|
||||
"idpErrorNotFound": "Nie znaleziono IdP",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Nieprawidłowe zaproszenie",
|
||||
"inviteInvalidDescription": "Link zapraszający jest nieprawidłowy.",
|
||||
"inviteErrorWrongUser": "Zaproszenie nie jest dla tego użytkownika",
|
||||
|
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Wymagana edycja Professional",
|
||||
"licenseTierProfessionalRequiredDescription": "Ta funkcja jest dostępna tylko w edycji Professional.",
|
||||
"actionGetOrg": "Pobierz organizację",
|
||||
"updateOrgUser": "Aktualizuj użytkownika Org",
|
||||
"createOrgUser": "Utwórz użytkownika Org",
|
||||
"actionUpdateOrg": "Aktualizuj organizację",
|
||||
"actionUpdateUser": "Zaktualizuj użytkownika",
|
||||
"actionGetUser": "Pobierz użytkownika",
|
||||
|
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Usuń witrynę",
|
||||
"actionGetSite": "Pobierz witrynę",
|
||||
"actionListSites": "Lista witryn",
|
||||
"actionApplyBlueprint": "Zastosuj schemat",
|
||||
"setupToken": "Skonfiguruj token",
|
||||
"setupTokenDescription": "Wprowadź token konfiguracji z konsoli serwera.",
|
||||
"setupTokenRequired": "Wymagany jest token konfiguracji",
|
||||
|
|
@ -1059,6 +1067,7 @@
|
|||
"actionGetSiteResource": "Pobierz zasób strony",
|
||||
"actionListSiteResources": "Lista zasobów strony",
|
||||
"actionUpdateSiteResource": "Aktualizuj zasób strony",
|
||||
"actionListInvitations": "Lista zaproszeń",
|
||||
"noneSelected": "Nie wybrano",
|
||||
"orgNotFound2": "Nie znaleziono organizacji.",
|
||||
"searchProgress": "Szukaj...",
|
||||
|
|
@ -1132,8 +1141,8 @@
|
|||
"sidebarLicense": "Licencja",
|
||||
"sidebarClients": "Klienci (Beta)",
|
||||
"sidebarDomains": "Domeny",
|
||||
"enableDockerSocket": "Włącz gniazdo dokera",
|
||||
"enableDockerSocketDescription": "Włącz wykrywanie Docker Socket w celu wypełnienia informacji o kontenerach. Ścieżka gniazda musi być dostarczona do Newt.",
|
||||
"enableDockerSocket": "Włącz schemat dokera",
|
||||
"enableDockerSocketDescription": "Włącz etykietowanie kieszeni dokującej dla etykiet schematów. Ścieżka do gniazda musi być dostarczona do Newt.",
|
||||
"enableDockerSocketLink": "Dowiedz się więcej",
|
||||
"viewDockerContainers": "Zobacz kontenery dokujące",
|
||||
"containersIn": "Pojemniki w {siteName}",
|
||||
|
|
@ -1233,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Dostępna aktualizacja",
|
||||
"newtUpdateAvailableInfo": "Nowa wersja Newt jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.",
|
||||
"domainPickerEnterDomain": "Domena",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com lub po prostu myapp",
|
||||
"domainPickerPlaceholder": "mojapp.example.com",
|
||||
"domainPickerDescription": "Wpisz pełną domenę zasobu, aby zobaczyć dostępne opcje.",
|
||||
"domainPickerDescriptionSaas": "Wprowadź pełną domenę, subdomenę lub po prostu nazwę, aby zobaczyć dostępne opcje",
|
||||
"domainPickerTabAll": "Wszystko",
|
||||
|
|
@ -1391,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protokół",
|
||||
"editInternalResourceDialogSitePort": "Port witryny",
|
||||
"editInternalResourceDialogTargetConfiguration": "Konfiguracja celu",
|
||||
"editInternalResourceDialogDestinationIP": "IP docelowe",
|
||||
"editInternalResourceDialogDestinationPort": "Port docelowy",
|
||||
"editInternalResourceDialogCancel": "Anuluj",
|
||||
"editInternalResourceDialogSaveResource": "Zapisz zasób",
|
||||
"editInternalResourceDialogSuccess": "Sukces",
|
||||
|
|
@ -1423,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Port witryny",
|
||||
"createInternalResourceDialogSitePortDescription": "Użyj tego portu, aby uzyskać dostęp do zasobu na stronie, gdy połączony z klientem.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Konfiguracja celu",
|
||||
"createInternalResourceDialogDestinationIP": "IP docelowe",
|
||||
"createInternalResourceDialogDestinationIPDescription": "Adres IP zasobu w sieci strony.",
|
||||
"createInternalResourceDialogDestinationPort": "Port docelowy",
|
||||
"createInternalResourceDialogDestinationIPDescription": "Adres IP lub nazwa hosta zasobu w sieci witryny.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "Port na docelowym IP, gdzie zasób jest dostępny.",
|
||||
"createInternalResourceDialogCancel": "Anuluj",
|
||||
"createInternalResourceDialogCreateResource": "Utwórz zasób",
|
||||
|
|
@ -1458,6 +1463,61 @@
|
|||
"autoLoginError": "Błąd automatycznego logowania",
|
||||
"autoLoginErrorNoRedirectUrl": "Nie otrzymano URL przekierowania od dostawcy tożsamości.",
|
||||
"autoLoginErrorGeneratingUrl": "Nie udało się wygenerować URL uwierzytelniania.",
|
||||
"internationaldomaindetected": "Wykryto domenę międzynarodową",
|
||||
"willbestoredas": "Będzie przechowywane jako:"
|
||||
"managedSelfHosted": {
|
||||
"title": "Zarządzane Samodzielnie-Hostingowane",
|
||||
"description": "Większa niezawodność i niska konserwacja serwera Pangolin z dodatkowymi dzwonkami i sygnałami",
|
||||
"introTitle": "Zarządzany samowystarczalny Pangolin",
|
||||
"introDescription": "jest opcją wdrażania zaprojektowaną dla osób, które chcą prostoty i dodatkowej niezawodności, przy jednoczesnym utrzymaniu swoich danych prywatnych i samodzielnych.",
|
||||
"introDetail": "Z tą opcją nadal obsługujesz swój własny węzeł Pangolin — tunele, zakończenie SSL i ruch na Twoim serwerze. Różnica polega na tym, że zarządzanie i monitorowanie odbywa się za pomocą naszej tablicy rozdzielczej, która odblokowuje szereg korzyści:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Uproszczone operacje",
|
||||
"description": "Nie ma potrzeby uruchamiania własnego serwera pocztowego lub ustawiania skomplikowanych powiadomień. Będziesz mieć kontrolę zdrowia i powiadomienia o przestoju."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Automatyczne aktualizacje",
|
||||
"description": "Panel chmury rozwija się szybko, więc otrzymujesz nowe funkcje i poprawki błędów bez konieczności ręcznego ciągnięcia nowych kontenerów za każdym razem."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Mniej konserwacji",
|
||||
"description": "Brak migracji bazy danych, kopii zapasowych lub dodatkowej infrastruktury do zarządzania. Obsługujemy to w chmurze."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Przegrywanie w chmurze",
|
||||
"description": "Jeśli Twój węzeł zostanie wyłączony, tunele mogą tymczasowo zawieść do naszych punktów w chmurze, dopóki nie przyniesiesz go z powrotem do trybu online."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "Wysoka dostępność (PoPs)",
|
||||
"description": "Możesz również dołączyć wiele węzłów do swojego konta w celu nadmiarowości i lepszej wydajności."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Przyszłe ulepszenia",
|
||||
"description": "Planujemy dodać więcej narzędzi analitycznych, ostrzegawczych i zarządzania, aby zwiększyć odporność wdrożenia."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Dowiedz się więcej o opcji zarządzania samodzielnym hostingiem w naszym",
|
||||
"documentation": "dokumentacja"
|
||||
},
|
||||
"convertButton": "Konwertuj ten węzeł do zarządzanego samodzielnie"
|
||||
},
|
||||
"internationaldomaindetected": "Wykryto międzynarodową domenę",
|
||||
"willbestoredas": "Będą przechowywane jako:",
|
||||
"idpGoogleDescription": "Dostawca Google OAuth2/OIDC",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Niestandardowe nagłówki",
|
||||
"headersValidationError": "Nagłówki muszą być w formacie: Nazwa nagłówka: wartość.",
|
||||
"domainPickerProvidedDomain": "Dostarczona domena",
|
||||
"domainPickerFreeProvidedDomain": "Darmowa oferowana domena",
|
||||
"domainPickerVerified": "Zweryfikowano",
|
||||
"domainPickerUnverified": "Niezweryfikowane",
|
||||
"domainPickerInvalidSubdomainStructure": "Ta subdomena zawiera nieprawidłowe znaki lub strukturę. Zostanie ona automatycznie oczyszczona po zapisaniu.",
|
||||
"domainPickerError": "Błąd",
|
||||
"domainPickerErrorLoadDomains": "Nie udało się załadować domen organizacji",
|
||||
"domainPickerErrorCheckAvailability": "Nie udało się sprawdzić dostępności domeny",
|
||||
"domainPickerInvalidSubdomain": "Nieprawidłowa subdomena",
|
||||
"domainPickerInvalidSubdomainRemoved": "Wejście \"{sub}\" zostało usunięte, ponieważ jest nieprawidłowe.",
|
||||
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" nie może być poprawne dla {domain}.",
|
||||
"domainPickerSubdomainSanitized": "Poddomena oczyszczona",
|
||||
"domainPickerSubdomainCorrected": "\"{sub}\" został skorygowany do \"{sanitized}\"",
|
||||
"resourceAddEntrypointsEditFile": "Edytuj plik: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Edytuj plik: docker-compose.yml"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "Ocorreu um erro ao adicionar usuário à função.",
|
||||
"userSaved": "Usuário salvo",
|
||||
"userSavedDescription": "O usuário foi atualizado.",
|
||||
"autoProvisioned": "Auto provisionado",
|
||||
"autoProvisionedDescription": "Permitir que este usuário seja gerenciado automaticamente pelo provedor de identidade",
|
||||
"accessControlsDescription": "Gerencie o que este usuário pode acessar e fazer na organização",
|
||||
"accessControlsSubmit": "Salvar Controles de Acesso",
|
||||
"roles": "Funções",
|
||||
|
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Formato de endereço IP inválido",
|
||||
"ipAddressErrorInvalidOctet": "Octeto de endereço IP inválido",
|
||||
"path": "Caminho",
|
||||
"matchPath": "Correspondência de caminho",
|
||||
"ipAddressRange": "Faixa de IP",
|
||||
"rulesErrorFetch": "Falha ao buscar regras",
|
||||
"rulesErrorFetchDescription": "Ocorreu um erro ao buscar regras",
|
||||
|
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Conectado",
|
||||
"idpErrorConnectingTo": "Ocorreu um problema ao ligar a {name}. Por favor, contacte o seu administrador.",
|
||||
"idpErrorNotFound": "IdP não encontrado",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Convite Inválido",
|
||||
"inviteInvalidDescription": "O link do convite é inválido.",
|
||||
"inviteErrorWrongUser": "O convite não é para este usuário",
|
||||
|
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Edição Profissional Necessária",
|
||||
"licenseTierProfessionalRequiredDescription": "Esta funcionalidade só está disponível na Edição Profissional.",
|
||||
"actionGetOrg": "Obter Organização",
|
||||
"updateOrgUser": "Atualizar usuário Org",
|
||||
"createOrgUser": "Criar usuário Org",
|
||||
"actionUpdateOrg": "Atualizar Organização",
|
||||
"actionUpdateUser": "Atualizar Usuário",
|
||||
"actionGetUser": "Obter Usuário",
|
||||
|
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Eliminar Site",
|
||||
"actionGetSite": "Obter Site",
|
||||
"actionListSites": "Listar Sites",
|
||||
"actionApplyBlueprint": "Aplicar Diagrama",
|
||||
"setupToken": "Configuração do Token",
|
||||
"setupTokenDescription": "Digite o token de configuração do console do servidor.",
|
||||
"setupTokenRequired": "Token de configuração é necessário",
|
||||
|
|
@ -1059,6 +1067,7 @@
|
|||
"actionGetSiteResource": "Obter Recurso do Site",
|
||||
"actionListSiteResources": "Listar Recursos do Site",
|
||||
"actionUpdateSiteResource": "Atualizar Recurso do Site",
|
||||
"actionListInvitations": "Listar Convites",
|
||||
"noneSelected": "Nenhum selecionado",
|
||||
"orgNotFound2": "Nenhuma organização encontrada.",
|
||||
"searchProgress": "Pesquisar...",
|
||||
|
|
@ -1132,8 +1141,8 @@
|
|||
"sidebarLicense": "Tipo:",
|
||||
"sidebarClients": "Clientes (Beta)",
|
||||
"sidebarDomains": "Domínios",
|
||||
"enableDockerSocket": "Habilitar Docker Socket",
|
||||
"enableDockerSocketDescription": "Ativar a descoberta do Docker Socket para preencher informações do contêiner. O caminho do socket deve ser fornecido ao Newt.",
|
||||
"enableDockerSocket": "Habilitar o Diagrama Docker",
|
||||
"enableDockerSocketDescription": "Ativar a scraping de rótulo Docker para rótulos de diagramas. Caminho de Socket deve ser fornecido para Newt.",
|
||||
"enableDockerSocketLink": "Saiba mais",
|
||||
"viewDockerContainers": "Ver contêineres Docker",
|
||||
"containersIn": "Contêineres em {siteName}",
|
||||
|
|
@ -1233,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Nova Atualização Disponível",
|
||||
"newtUpdateAvailableInfo": "Uma nova versão do Newt está disponível. Atualize para a versão mais recente para uma melhor experiência.",
|
||||
"domainPickerEnterDomain": "Domínio",
|
||||
"domainPickerPlaceholder": "meuapp.exemplo.com, api.v1.meudominio.com, ou apenas meuapp",
|
||||
"domainPickerPlaceholder": "myapp.exemplo.com",
|
||||
"domainPickerDescription": "Insira o domínio completo do recurso para ver as opções disponíveis.",
|
||||
"domainPickerDescriptionSaas": "Insira um domínio completo, subdomínio ou apenas um nome para ver as opções disponíveis",
|
||||
"domainPickerTabAll": "Todos",
|
||||
|
|
@ -1391,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protocolo",
|
||||
"editInternalResourceDialogSitePort": "Porta do Site",
|
||||
"editInternalResourceDialogTargetConfiguration": "Configuração do Alvo",
|
||||
"editInternalResourceDialogDestinationIP": "IP de Destino",
|
||||
"editInternalResourceDialogDestinationPort": "Porta de Destino",
|
||||
"editInternalResourceDialogCancel": "Cancelar",
|
||||
"editInternalResourceDialogSaveResource": "Salvar Recurso",
|
||||
"editInternalResourceDialogSuccess": "Sucesso",
|
||||
|
|
@ -1423,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Porta do Site",
|
||||
"createInternalResourceDialogSitePortDescription": "Use esta porta para acessar o recurso no site quando conectado com um cliente.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Configuração do Alvo",
|
||||
"createInternalResourceDialogDestinationIP": "IP de Destino",
|
||||
"createInternalResourceDialogDestinationIPDescription": "O endereço IP do recurso na rede do site.",
|
||||
"createInternalResourceDialogDestinationPort": "Porta de Destino",
|
||||
"createInternalResourceDialogDestinationIPDescription": "O IP ou endereço do hostname do recurso na rede do site.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "A porta no IP de destino onde o recurso está acessível.",
|
||||
"createInternalResourceDialogCancel": "Cancelar",
|
||||
"createInternalResourceDialogCreateResource": "Criar Recurso",
|
||||
|
|
@ -1458,6 +1463,61 @@
|
|||
"autoLoginError": "Erro de Login Automático",
|
||||
"autoLoginErrorNoRedirectUrl": "Nenhum URL de redirecionamento recebido do provedor de identidade.",
|
||||
"autoLoginErrorGeneratingUrl": "Falha ao gerar URL de autenticação.",
|
||||
"internationaldomaindetected": "Domínio internacional detetado",
|
||||
"willbestoredas": "Será armazenado como:"
|
||||
"managedSelfHosted": {
|
||||
"title": "Gerenciado Auto-Hospedado",
|
||||
"description": "Servidor Pangolin auto-hospedado mais confiável e com baixa manutenção com sinos extras e assobiamentos",
|
||||
"introTitle": "Pangolin Auto-Hospedado Gerenciado",
|
||||
"introDescription": "é uma opção de implantação projetada para pessoas que querem simplicidade e confiança adicional, mantendo os seus dados privados e auto-hospedados.",
|
||||
"introDetail": "Com esta opção, você ainda roda seu próprio nó Pangolin — seus túneis, terminação SSL e tráfego todos permanecem no seu servidor. A diferença é que a gestão e a monitorização são geridos através do nosso painel de nuvem, que desbloqueia vários benefícios:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Operações simples",
|
||||
"description": "Não é necessário executar o seu próprio servidor de e-mail ou configurar um alerta complexo. Você receberá fora de caixa verificações de saúde e alertas de tempo de inatividade."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Atualizações automáticas",
|
||||
"description": "O painel em nuvem evolui rapidamente, para que você obtenha novos recursos e correções de bugs sem ter de puxar manualmente novos contêineres toda vez."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Menos manutenção",
|
||||
"description": "Sem migrações, backups ou infraestrutura extra para gerenciar. Lidamos com isso na nuvem."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Falha na nuvem",
|
||||
"description": "Se o seu nó descer, seus túneis podem falhar temporariamente nos nossos pontos de presença na nuvem até que você o traga online."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "Alta disponibilidade (Ppos)",
|
||||
"description": "Você também pode anexar vários nós à sua conta para um melhor desempenho."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Aprimoramentos futuros",
|
||||
"description": "Estamos planejando adicionar mais análises, alertas e ferramentas de gerenciamento para tornar sua implantação ainda mais robusta."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Saiba mais sobre a opção Hospedagem Auto-Gerenciada no nosso",
|
||||
"documentation": "documentação"
|
||||
},
|
||||
"convertButton": "Converter este nó para Auto-Hospedado Gerenciado"
|
||||
},
|
||||
"internationaldomaindetected": "Domínio Internacional Detectado",
|
||||
"willbestoredas": "Será armazenado como:",
|
||||
"idpGoogleDescription": "Provedor Google OAuth2/OIDC",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Cabeçalhos Personalizados",
|
||||
"headersValidationError": "Cabeçalhos devem estar no formato: Nome do Cabeçalho: valor.",
|
||||
"domainPickerProvidedDomain": "Domínio fornecido",
|
||||
"domainPickerFreeProvidedDomain": "Domínio fornecido grátis",
|
||||
"domainPickerVerified": "Verificada",
|
||||
"domainPickerUnverified": "Não verificado",
|
||||
"domainPickerInvalidSubdomainStructure": "Este subdomínio contém caracteres ou estrutura inválidos. Ele será eliminado automaticamente quando você salvar.",
|
||||
"domainPickerError": "ERRO",
|
||||
"domainPickerErrorLoadDomains": "Falha ao carregar domínios da organização",
|
||||
"domainPickerErrorCheckAvailability": "Não foi possível verificar a disponibilidade do domínio",
|
||||
"domainPickerInvalidSubdomain": "Subdomínio inválido",
|
||||
"domainPickerInvalidSubdomainRemoved": "A entrada \"{sub}\" foi removida porque ela não é válida.",
|
||||
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" não pôde ser válido para {domain}.",
|
||||
"domainPickerSubdomainSanitized": "Subdomínio banalizado",
|
||||
"domainPickerSubdomainCorrected": "\"{sub}\" foi corrigido para \"{sanitized}\"",
|
||||
"resourceAddEntrypointsEditFile": "Editar arquivo: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Editar arquivo: docker-compose.yml"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "Произошла ошибка при добавлении пользователя в роль.",
|
||||
"userSaved": "Пользователь сохранён",
|
||||
"userSavedDescription": "Пользователь был обновлён.",
|
||||
"autoProvisioned": "Автоподбор",
|
||||
"autoProvisionedDescription": "Разрешить автоматическое управление этим пользователем",
|
||||
"accessControlsDescription": "Управляйте тем, к чему этот пользователь может получить доступ и что делать в организации",
|
||||
"accessControlsSubmit": "Сохранить контроль доступа",
|
||||
"roles": "Роли",
|
||||
|
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Неверный формат IP адреса",
|
||||
"ipAddressErrorInvalidOctet": "Неверный октет IP адреса",
|
||||
"path": "Путь",
|
||||
"matchPath": "Путь матча",
|
||||
"ipAddressRange": "Диапазон IP",
|
||||
"rulesErrorFetch": "Не удалось получить правила",
|
||||
"rulesErrorFetchDescription": "Произошла ошибка при получении правил",
|
||||
|
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Подключено",
|
||||
"idpErrorConnectingTo": "Возникла проблема при подключении к {name}. Пожалуйста, свяжитесь с вашим администратором.",
|
||||
"idpErrorNotFound": "IdP не найден",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Недействительное приглашение",
|
||||
"inviteInvalidDescription": "Ссылка на приглашение недействительна.",
|
||||
"inviteErrorWrongUser": "Приглашение не для этого пользователя",
|
||||
|
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Требуется профессиональная версия",
|
||||
"licenseTierProfessionalRequiredDescription": "Эта функция доступна только в профессиональной версии.",
|
||||
"actionGetOrg": "Получить организацию",
|
||||
"updateOrgUser": "Обновить пользователя Org",
|
||||
"createOrgUser": "Создать пользователя Org",
|
||||
"actionUpdateOrg": "Обновить организацию",
|
||||
"actionUpdateUser": "Обновить пользователя",
|
||||
"actionGetUser": "Получить пользователя",
|
||||
|
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Удалить сайт",
|
||||
"actionGetSite": "Получить сайт",
|
||||
"actionListSites": "Список сайтов",
|
||||
"actionApplyBlueprint": "Применить чертёж",
|
||||
"setupToken": "Код настройки",
|
||||
"setupTokenDescription": "Введите токен настройки из консоли сервера.",
|
||||
"setupTokenRequired": "Токен настройки обязателен",
|
||||
|
|
@ -1059,6 +1067,7 @@
|
|||
"actionGetSiteResource": "Получить ресурс сайта",
|
||||
"actionListSiteResources": "Список ресурсов сайта",
|
||||
"actionUpdateSiteResource": "Обновить ресурс сайта",
|
||||
"actionListInvitations": "Список приглашений",
|
||||
"noneSelected": "Ничего не выбрано",
|
||||
"orgNotFound2": "Организации не найдены.",
|
||||
"searchProgress": "Поиск...",
|
||||
|
|
@ -1132,8 +1141,8 @@
|
|||
"sidebarLicense": "Лицензия",
|
||||
"sidebarClients": "Клиенты (бета)",
|
||||
"sidebarDomains": "Домены",
|
||||
"enableDockerSocket": "Включить Docker Socket",
|
||||
"enableDockerSocketDescription": "Включить обнаружение Docker Socket для заполнения информации о контейнерах. Путь к сокету должен быть предоставлен Newt.",
|
||||
"enableDockerSocket": "Включить чертёж Docker",
|
||||
"enableDockerSocketDescription": "Включить scraping ярлыка Docker Socket для ярлыков чертежей. Путь к сокету должен быть предоставлен в Newt.",
|
||||
"enableDockerSocketLink": "Узнать больше",
|
||||
"viewDockerContainers": "Просмотр контейнеров Docker",
|
||||
"containersIn": "Контейнеры в {siteName}",
|
||||
|
|
@ -1233,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Доступно обновление",
|
||||
"newtUpdateAvailableInfo": "Доступна новая версия Newt. Пожалуйста, обновитесь до последней версии для лучшего опыта.",
|
||||
"domainPickerEnterDomain": "Домен",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, или просто myapp",
|
||||
"domainPickerPlaceholder": "myapp.example.com",
|
||||
"domainPickerDescription": "Введите полный домен ресурса, чтобы увидеть доступные опции.",
|
||||
"domainPickerDescriptionSaas": "Введите полный домен, поддомен или просто имя, чтобы увидеть доступные опции",
|
||||
"domainPickerTabAll": "Все",
|
||||
|
|
@ -1391,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Протокол",
|
||||
"editInternalResourceDialogSitePort": "Порт сайта",
|
||||
"editInternalResourceDialogTargetConfiguration": "Настройка цели",
|
||||
"editInternalResourceDialogDestinationIP": "Целевая IP",
|
||||
"editInternalResourceDialogDestinationPort": "Целевой порт",
|
||||
"editInternalResourceDialogCancel": "Отмена",
|
||||
"editInternalResourceDialogSaveResource": "Сохранить ресурс",
|
||||
"editInternalResourceDialogSuccess": "Успешно",
|
||||
|
|
@ -1423,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Порт сайта",
|
||||
"createInternalResourceDialogSitePortDescription": "Используйте этот порт для доступа к ресурсу на сайте при подключении с клиентом.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Настройка цели",
|
||||
"createInternalResourceDialogDestinationIP": "Целевая IP",
|
||||
"createInternalResourceDialogDestinationIPDescription": "IP-адрес ресурса в сети сайта.",
|
||||
"createInternalResourceDialogDestinationPort": "Целевой порт",
|
||||
"createInternalResourceDialogDestinationIPDescription": "IP или адрес хоста ресурса в сети сайта.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "Порт на IP-адресе назначения, где доступен ресурс.",
|
||||
"createInternalResourceDialogCancel": "Отмена",
|
||||
"createInternalResourceDialogCreateResource": "Создать ресурс",
|
||||
|
|
@ -1458,6 +1463,61 @@
|
|||
"autoLoginError": "Ошибка автоматического входа",
|
||||
"autoLoginErrorNoRedirectUrl": "URL-адрес перенаправления не получен от провайдера удостоверения.",
|
||||
"autoLoginErrorGeneratingUrl": "Не удалось сгенерировать URL-адрес аутентификации.",
|
||||
"managedSelfHosted": {
|
||||
"title": "Управляемый с самовывоза",
|
||||
"description": "Более надежный и низко обслуживаемый сервер Pangolin с дополнительными колокольнями и свистками",
|
||||
"introTitle": "Управляемый Само-Хост Панголина",
|
||||
"introDescription": "- это вариант развертывания, предназначенный для людей, которые хотят простоты и надёжности, сохраняя при этом свои данные конфиденциальными и самостоятельными.",
|
||||
"introDetail": "С помощью этой опции вы по-прежнему используете узел Pangolin — туннели, SSL, и весь остающийся на вашем сервере. Разница заключается в том, что управление и мониторинг осуществляются через нашу панель инструментов из облака, которая открывает ряд преимуществ:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Более простые операции",
|
||||
"description": "Не нужно запускать свой собственный почтовый сервер или настроить комплексное оповещение. Вы будете получать проверки состояния здоровья и оповещения о неисправностях из коробки."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Автоматическое обновление",
|
||||
"description": "Панель управления в облаке развивается быстро, так что вы получаете новые функции и исправления ошибок, без необходимости каждый раз получать новые контейнеры."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Меньше обслуживания",
|
||||
"description": "Нет миграции баз данных, резервных копий или дополнительной инфраструктуры для управления. Мы обрабатываем это в облаке."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Облачное срабатывание",
|
||||
"description": "Если ваш узел исчезнет, ваши туннели могут временно прерваться до наших облачных точек присутствия, пока вы не вернете его в сети."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "Высокая доступность (PoP)",
|
||||
"description": "Вы также можете прикрепить несколько узлов к вашему аккаунту для избыточности и лучшей производительности."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Будущие улучшения",
|
||||
"description": "Мы планируем добавить дополнительные инструменты аналитики, оповещения и управления, чтобы сделать установку еще более надежной."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Узнайте больше о опции Managed Self-Hosted в нашей",
|
||||
"documentation": "документация"
|
||||
},
|
||||
"convertButton": "Конвертировать этот узел в управляемый себе-хост"
|
||||
},
|
||||
"internationaldomaindetected": "Обнаружен международный домен",
|
||||
"willbestoredas": "Будет сохранен как:"
|
||||
"willbestoredas": "Будет храниться как:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC провайдер",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Пользовательские заголовки",
|
||||
"headersValidationError": "Заголовки должны быть в формате: Название заголовка: значение.",
|
||||
"domainPickerProvidedDomain": "Домен предоставлен",
|
||||
"domainPickerFreeProvidedDomain": "Бесплатный домен",
|
||||
"domainPickerVerified": "Подтверждено",
|
||||
"domainPickerUnverified": "Не подтверждено",
|
||||
"domainPickerInvalidSubdomainStructure": "Этот поддомен содержит недопустимые символы или структуру. Он будет очищен автоматически при сохранении.",
|
||||
"domainPickerError": "Ошибка",
|
||||
"domainPickerErrorLoadDomains": "Не удалось загрузить домены организации",
|
||||
"domainPickerErrorCheckAvailability": "Не удалось проверить доступность домена",
|
||||
"domainPickerInvalidSubdomain": "Неверный поддомен",
|
||||
"domainPickerInvalidSubdomainRemoved": "Ввод \"{sub}\" был удален, потому что он недействителен.",
|
||||
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" не может быть действительным для {domain}.",
|
||||
"domainPickerSubdomainSanitized": "Субдомен очищен",
|
||||
"domainPickerSubdomainCorrected": "\"{sub}\" был исправлен на \"{sanitized}\"",
|
||||
"resourceAddEntrypointsEditFile": "Редактировать файл: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Редактировать файл: docker-compose.yml"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "Kullanıcı role eklenirken bir hata oluştu.",
|
||||
"userSaved": "Kullanıcı kaydedildi",
|
||||
"userSavedDescription": "Kullanıcı güncellenmiştir.",
|
||||
"autoProvisioned": "Otomatik Sağlandı",
|
||||
"autoProvisionedDescription": "Bu kullanıcının kimlik sağlayıcısı tarafından otomatik olarak yönetilmesine izin ver",
|
||||
"accessControlsDescription": "Bu kullanıcının organizasyonda neleri erişebileceğini ve yapabileceğini yönetin",
|
||||
"accessControlsSubmit": "Erişim Kontrollerini Kaydet",
|
||||
"roles": "Roller",
|
||||
|
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Geçersiz IP adresi formatı",
|
||||
"ipAddressErrorInvalidOctet": "Geçersiz IP adresi okteti",
|
||||
"path": "Yol",
|
||||
"matchPath": "Yol Eşleştir",
|
||||
"ipAddressRange": "IP Aralığı",
|
||||
"rulesErrorFetch": "Kurallar alınamadı",
|
||||
"rulesErrorFetchDescription": "Kurallar alınırken bir hata oluştu",
|
||||
|
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Bağlandı",
|
||||
"idpErrorConnectingTo": "{name} ile bağlantı kurarken bir sorun meydana geldi. Lütfen yöneticiye danışın.",
|
||||
"idpErrorNotFound": "IdP bulunamadı",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Geçersiz Davet",
|
||||
"inviteInvalidDescription": "Davet bağlantısı geçersiz.",
|
||||
"inviteErrorWrongUser": "Davet bu kullanıcı için değil",
|
||||
|
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Profesyonel Sürüme Gereklidir",
|
||||
"licenseTierProfessionalRequiredDescription": "Bu özellik yalnızca Professional Edition'da kullanılabilir.",
|
||||
"actionGetOrg": "Kuruluşu Al",
|
||||
"updateOrgUser": "Organizasyon Kullanıcısını Güncelle",
|
||||
"createOrgUser": "Organizasyon Kullanıcısı Oluştur",
|
||||
"actionUpdateOrg": "Kuruluşu Güncelle",
|
||||
"actionUpdateUser": "Kullanıcıyı Güncelle",
|
||||
"actionGetUser": "Kullanıcıyı Getir",
|
||||
|
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Siteyi Sil",
|
||||
"actionGetSite": "Siteyi Al",
|
||||
"actionListSites": "Siteleri Listele",
|
||||
"actionApplyBlueprint": "Planı Uygula",
|
||||
"setupToken": "Kurulum Simgesi",
|
||||
"setupTokenDescription": "Sunucu konsolundan kurulum simgesini girin.",
|
||||
"setupTokenRequired": "Kurulum simgesi gerekli",
|
||||
|
|
@ -1059,6 +1067,7 @@
|
|||
"actionGetSiteResource": "Site Kaynağını Al",
|
||||
"actionListSiteResources": "Site Kaynaklarını Listele",
|
||||
"actionUpdateSiteResource": "Site Kaynağını Güncelle",
|
||||
"actionListInvitations": "Davetiyeleri Listele",
|
||||
"noneSelected": "Hiçbiri seçili değil",
|
||||
"orgNotFound2": "Hiçbir organizasyon bulunamadı.",
|
||||
"searchProgress": "Ara...",
|
||||
|
|
@ -1132,8 +1141,8 @@
|
|||
"sidebarLicense": "Lisans",
|
||||
"sidebarClients": "Müşteriler (Beta)",
|
||||
"sidebarDomains": "Alan Adları",
|
||||
"enableDockerSocket": "Docker Soketi Etkinleştir",
|
||||
"enableDockerSocketDescription": "Konteyner bilgilerini doldurmak için Docker Socket keşfini etkinleştirin. Socket yolu Newt'e sağlanmalıdır.",
|
||||
"enableDockerSocket": "Docker Soketini Etkinleştir",
|
||||
"enableDockerSocketDescription": "Plan etiketleri için Docker Socket etiket toplamasını etkinleştirin. Newt'e soket yolu sağlanmalıdır.",
|
||||
"enableDockerSocketLink": "Daha fazla bilgi",
|
||||
"viewDockerContainers": "Docker Konteynerlerini Görüntüle",
|
||||
"containersIn": "{siteName} içindeki konteynerler",
|
||||
|
|
@ -1233,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Güncelleme Mevcut",
|
||||
"newtUpdateAvailableInfo": "Newt'in yeni bir versiyonu mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.",
|
||||
"domainPickerEnterDomain": "Domain",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com veya sadece myapp",
|
||||
"domainPickerPlaceholder": "myapp.example.com",
|
||||
"domainPickerDescription": "Mevcut seçenekleri görmek için kaynağın tam etki alanını girin.",
|
||||
"domainPickerDescriptionSaas": "Mevcut seçenekleri görmek için tam etki alanı, alt etki alanı veya sadece bir isim girin",
|
||||
"domainPickerTabAll": "Tümü",
|
||||
|
|
@ -1391,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protokol",
|
||||
"editInternalResourceDialogSitePort": "Site Bağlantı Noktası",
|
||||
"editInternalResourceDialogTargetConfiguration": "Hedef Yapılandırma",
|
||||
"editInternalResourceDialogDestinationIP": "Hedef IP",
|
||||
"editInternalResourceDialogDestinationPort": "Hedef Bağlantı Noktası",
|
||||
"editInternalResourceDialogCancel": "İptal",
|
||||
"editInternalResourceDialogSaveResource": "Kaynağı Kaydet",
|
||||
"editInternalResourceDialogSuccess": "Başarı",
|
||||
|
|
@ -1423,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Site Bağlantı Noktası",
|
||||
"createInternalResourceDialogSitePortDescription": "İstemci ile bağlanıldığında site üzerindeki kaynağa erişmek için bu bağlantı noktasını kullanın.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Hedef Yapılandırma",
|
||||
"createInternalResourceDialogDestinationIP": "Hedef IP",
|
||||
"createInternalResourceDialogDestinationIPDescription": "Site ağındaki kaynağın IP adresi.",
|
||||
"createInternalResourceDialogDestinationPort": "Hedef Bağlantı Noktası",
|
||||
"createInternalResourceDialogDestinationIPDescription": "Kaynağın site ağındaki IP veya ana bilgisayar adresi.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "Kaynağa erişilebilecek hedef IP üzerindeki bağlantı noktası.",
|
||||
"createInternalResourceDialogCancel": "İptal",
|
||||
"createInternalResourceDialogCreateResource": "Kaynak Oluştur",
|
||||
|
|
@ -1458,6 +1463,61 @@
|
|||
"autoLoginError": "Otomatik Giriş Hatası",
|
||||
"autoLoginErrorNoRedirectUrl": "Kimlik sağlayıcıdan yönlendirme URL'si alınamadı.",
|
||||
"autoLoginErrorGeneratingUrl": "Kimlik doğrulama URL'si oluşturulamadı.",
|
||||
"internationaldomaindetected": "Uluslararası Etki Alanı Algılandı",
|
||||
"willbestoredas": "Şu şekilde saklanacaktır:"
|
||||
"managedSelfHosted": {
|
||||
"title": "Yönetilen Self-Hosted",
|
||||
"description": "Daha güvenilir ve düşük bakım gerektiren, ekstra özelliklere sahip kendi kendine barındırabileceğiniz Pangolin sunucusu",
|
||||
"introTitle": "Yönetilen Kendi Kendine Barındırılan Pangolin",
|
||||
"introDescription": "Bu, basitlik ve ekstra güvenilirlik arayan, ancak verilerini gizli tutmak ve kendi sunucularında barındırmak isteyen kişiler için tasarlanmış bir dağıtım seçeneğidir.",
|
||||
"introDetail": "Bu seçenekle, kendi Pangolin düğümünüzü çalıştırmaya devam edersiniz — tünelleriniz, SSL bitişiniz ve trafiğiniz tamamen sunucunuzda kalır. Fark, yönetim ve izlemeyi bulut panomuz üzerinden gerçekleştiririz, bu da bir dizi avantaj sağlar:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Daha basit işlemler",
|
||||
"description": "Kendi e-posta sunucunuzu çalıştırmanıza veya karmaşık uyarılar kurmanıza gerek yok. Sağlık kontrolleri ve kesinti uyarılarını kutudan çıktığı gibi alırsınız."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Otomatik güncellemeler",
|
||||
"description": "Bulut panosu hızla gelişir, böylece her seferinde yeni konteynerler manuel olarak çekmeden yeni özellikler ve hata düzeltmeleri alırsınız."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Daha az bakım",
|
||||
"description": "Veritabanı geçişleri, yedeklemeler veya ekstra altyapı yönetimi yok. Biz bunu bulutta hallederiz."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Bulut yedekleme",
|
||||
"description": "Düğümünüz kapandığında, tünelleriniz geçici olarak bulut bağlantı noktalarımıza geçebilir, böylece tekrar çevrimiçi hale getirene kadar tünelleriniz kesintiye uğramaz."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "Yüksek kullanılabilirlik (Bağlantı Noktaları)",
|
||||
"description": "Yedeklilik ve daha iyi performans için hesabınıza birden fazla düğüm bağlayabilirsiniz."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Gelecek iyileştirmeler",
|
||||
"description": "Dağıtımınızı daha sağlam hale getirmek amacıyla daha fazla analiz, uyarı ve yönetim aracı eklemeyi planlıyoruz."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Yönetilen Kendi Kendine Barındırılan seçeneği hakkında daha fazla bilgi edinin",
|
||||
"documentation": "dokümantasyon"
|
||||
},
|
||||
"convertButton": "Bu Düğümü Yönetilen Kendi Kendine Barındırma Dönüştürün"
|
||||
},
|
||||
"internationaldomaindetected": "Uluslararası Alan Adı Tespit Edildi",
|
||||
"willbestoredas": "Şu şekilde depolanacak:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC sağlayıcısı",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC sağlayıcısı",
|
||||
"customHeaders": "Özel Başlıklar",
|
||||
"headersValidationError": "Başlıklar şu formatta olmalıdır: Başlık-Adı: değer.",
|
||||
"domainPickerProvidedDomain": "Sağlanan Alan Adı",
|
||||
"domainPickerFreeProvidedDomain": "Ücretsiz Sağlanan Alan Adı",
|
||||
"domainPickerVerified": "Doğrulandı",
|
||||
"domainPickerUnverified": "Doğrulanmadı",
|
||||
"domainPickerInvalidSubdomainStructure": "Bu alt alan adı geçersiz karakterler veya yapı içeriyor. Kaydettiğinizde otomatik olarak temizlenecektir.",
|
||||
"domainPickerError": "Hata",
|
||||
"domainPickerErrorLoadDomains": "Organizasyon alan adları yüklenemedi",
|
||||
"domainPickerErrorCheckAvailability": "Alan adı kullanılabilirliği kontrol edilemedi",
|
||||
"domainPickerInvalidSubdomain": "Geçersiz alt alan adı",
|
||||
"domainPickerInvalidSubdomainRemoved": "Girdi \"{sub}\" geçersiz olduğu için kaldırıldı.",
|
||||
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" {domain} için geçerli yapılamadı.",
|
||||
"domainPickerSubdomainSanitized": "Alt alan adı temizlendi",
|
||||
"domainPickerSubdomainCorrected": "\"{sub}\" \"{sanitized}\" olarak düzeltildi",
|
||||
"resourceAddEntrypointsEditFile": "Dosyayı düzenle: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Dosyayı düzenle: docker-compose.yml"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "添加用户到角色时出错。",
|
||||
"userSaved": "用户已保存",
|
||||
"userSavedDescription": "用户已更新。",
|
||||
"autoProvisioned": "自动设置",
|
||||
"autoProvisionedDescription": "允许此用户由身份提供商自动管理",
|
||||
"accessControlsDescription": "管理此用户在组织中可以访问和做什么",
|
||||
"accessControlsSubmit": "保存访问控制",
|
||||
"roles": "角色",
|
||||
|
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "无效的 IP 地址格式",
|
||||
"ipAddressErrorInvalidOctet": "无效的 IP 地址",
|
||||
"path": "路径",
|
||||
"matchPath": "匹配路径",
|
||||
"ipAddressRange": "IP 范围",
|
||||
"rulesErrorFetch": "获取规则失败",
|
||||
"rulesErrorFetchDescription": "获取规则时出错",
|
||||
|
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "已连接",
|
||||
"idpErrorConnectingTo": "无法连接到 {name},请联系管理员协助处理。",
|
||||
"idpErrorNotFound": "找不到 IdP",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "无效邀请",
|
||||
"inviteInvalidDescription": "邀请链接无效。",
|
||||
"inviteErrorWrongUser": "邀请不是该用户的",
|
||||
|
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "需要专业版",
|
||||
"licenseTierProfessionalRequiredDescription": "此功能仅在专业版可用。",
|
||||
"actionGetOrg": "获取组织",
|
||||
"updateOrgUser": "更新组织用户",
|
||||
"createOrgUser": "创建组织用户",
|
||||
"actionUpdateOrg": "更新组织",
|
||||
"actionUpdateUser": "更新用户",
|
||||
"actionGetUser": "获取用户",
|
||||
|
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "删除站点",
|
||||
"actionGetSite": "获取站点",
|
||||
"actionListSites": "站点列表",
|
||||
"actionApplyBlueprint": "应用蓝图",
|
||||
"setupToken": "设置令牌",
|
||||
"setupTokenDescription": "从服务器控制台输入设置令牌。",
|
||||
"setupTokenRequired": "需要设置令牌",
|
||||
|
|
@ -1059,6 +1067,7 @@
|
|||
"actionGetSiteResource": "获取站点资源",
|
||||
"actionListSiteResources": "列出站点资源",
|
||||
"actionUpdateSiteResource": "更新站点资源",
|
||||
"actionListInvitations": "邀请列表",
|
||||
"noneSelected": "未选择",
|
||||
"orgNotFound2": "未找到组织。",
|
||||
"searchProgress": "搜索中...",
|
||||
|
|
@ -1132,8 +1141,8 @@
|
|||
"sidebarLicense": "证书",
|
||||
"sidebarClients": "客户端(测试版)",
|
||||
"sidebarDomains": "域",
|
||||
"enableDockerSocket": "启用停靠套接字",
|
||||
"enableDockerSocketDescription": "启用 Docker Socket 发现以填充容器信息。必须向 Newt 提供 Socket 路径。",
|
||||
"enableDockerSocket": "启用 Docker 蓝图",
|
||||
"enableDockerSocketDescription": "启用 Docker Socket 标签擦除蓝图标签。套接字路径必须提供给新的。",
|
||||
"enableDockerSocketLink": "了解更多",
|
||||
"viewDockerContainers": "查看停靠容器",
|
||||
"containersIn": "{siteName} 中的容器",
|
||||
|
|
@ -1233,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "更新可用",
|
||||
"newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。",
|
||||
"domainPickerEnterDomain": "域名",
|
||||
"domainPickerPlaceholder": "myapp.example.com、api.v1.mydomain.com 或仅 myapp",
|
||||
"domainPickerPlaceholder": "example.com",
|
||||
"domainPickerDescription": "输入资源的完整域名以查看可用选项。",
|
||||
"domainPickerDescriptionSaas": "输入完整域名、子域或名称以查看可用选项。",
|
||||
"domainPickerTabAll": "所有",
|
||||
|
|
@ -1391,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "协议",
|
||||
"editInternalResourceDialogSitePort": "站点端口",
|
||||
"editInternalResourceDialogTargetConfiguration": "目标配置",
|
||||
"editInternalResourceDialogDestinationIP": "目标IP",
|
||||
"editInternalResourceDialogDestinationPort": "目标端口",
|
||||
"editInternalResourceDialogCancel": "取消",
|
||||
"editInternalResourceDialogSaveResource": "保存资源",
|
||||
"editInternalResourceDialogSuccess": "成功",
|
||||
|
|
@ -1423,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "站点端口",
|
||||
"createInternalResourceDialogSitePortDescription": "使用此端口在连接到客户端时访问站点上的资源。",
|
||||
"createInternalResourceDialogTargetConfiguration": "目标配置",
|
||||
"createInternalResourceDialogDestinationIP": "目标IP",
|
||||
"createInternalResourceDialogDestinationIPDescription": "站点网络上资源的IP地址。",
|
||||
"createInternalResourceDialogDestinationPort": "目标端口",
|
||||
"createInternalResourceDialogDestinationIPDescription": "站点网络上资源的IP或主机名地址。",
|
||||
"createInternalResourceDialogDestinationPortDescription": "资源在目标IP上可访问的端口。",
|
||||
"createInternalResourceDialogCancel": "取消",
|
||||
"createInternalResourceDialogCreateResource": "创建资源",
|
||||
|
|
@ -1458,6 +1463,61 @@
|
|||
"autoLoginError": "自动登录错误",
|
||||
"autoLoginErrorNoRedirectUrl": "未从身份提供商收到重定向URL。",
|
||||
"autoLoginErrorGeneratingUrl": "生成身份验证URL失败。",
|
||||
"internationaldomaindetected": "检测到国际域名",
|
||||
"willbestoredas": "将存储为:"
|
||||
"managedSelfHosted": {
|
||||
"title": "托管自托管",
|
||||
"description": "更可靠和低维护自我托管的 Pangolin 服务器,带有额外的铃声和告密器",
|
||||
"introTitle": "托管自托管的潘戈林公司",
|
||||
"introDescription": "这是一种部署选择,为那些希望简洁和额外可靠的人设计,同时仍然保持他们的数据的私密性和自我托管性。",
|
||||
"introDetail": "通过此选项,您仍然运行您自己的 Pangolin 节点 — — 您的隧道、SSL 终止,并且流量在您的服务器上保持所有状态。 不同之处在于,管理和监测是通过我们的云层仪表板进行的,该仪表板开启了一些好处:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "简单的操作",
|
||||
"description": "无需运行您自己的邮件服务器或设置复杂的警报。您将从方框中获得健康检查和下限提醒。"
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "自动更新",
|
||||
"description": "云仪表盘快速演化,所以您可以获得新的功能和错误修复,而不必每次手动拉取新的容器。"
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "减少维护时间",
|
||||
"description": "没有要管理的数据库迁移、备份或额外的基础设施。我们在云端处理这个问题。"
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "云失败",
|
||||
"description": "如果您的节点被关闭,您的隧道可能暂时无法连接到我们的云端,直到您将其重新连接上线。"
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "高可用率(PoPs)",
|
||||
"description": "您还可以将多个节点添加到您的帐户中以获取冗余和更好的性能。"
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "将来的改进",
|
||||
"description": "我们正在计划添加更多的分析、警报和管理工具,使你的部署更加有力。"
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "在我们中更多地了解管理下的自托管选项",
|
||||
"documentation": "文档"
|
||||
},
|
||||
"convertButton": "将此节点转换为管理自托管的"
|
||||
},
|
||||
"internationaldomaindetected": "检测到国际域",
|
||||
"willbestoredas": "储存为:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC 提供商",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "自定义标题",
|
||||
"headersValidationError": "头部必须是格式:头部名称:值。",
|
||||
"domainPickerProvidedDomain": "提供的域",
|
||||
"domainPickerFreeProvidedDomain": "免费提供的域",
|
||||
"domainPickerVerified": "已验证",
|
||||
"domainPickerUnverified": "未验证",
|
||||
"domainPickerInvalidSubdomainStructure": "此子域包含无效的字符或结构。当您保存时,它将被自动清除。",
|
||||
"domainPickerError": "错误",
|
||||
"domainPickerErrorLoadDomains": "加载组织域名失败",
|
||||
"domainPickerErrorCheckAvailability": "检查域可用性失败",
|
||||
"domainPickerInvalidSubdomain": "无效的子域",
|
||||
"domainPickerInvalidSubdomainRemoved": "输入 \"{sub}\" 已被移除,因为其无效。",
|
||||
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" 无法为 {domain} 变为有效。",
|
||||
"domainPickerSubdomainSanitized": "子域已净化",
|
||||
"domainPickerSubdomainCorrected": "\"{sub}\" 已被更正为 \"{sanitized}\"",
|
||||
"resourceAddEntrypointsEditFile": "编辑文件:config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "编辑文件:docker-compose.yml"
|
||||
}
|
||||
|
|
|
|||
2276
package-lock.json
generated
2276
package-lock.json
generated
File diff suppressed because it is too large
Load diff
54
package.json
54
package.json
|
|
@ -21,13 +21,13 @@
|
|||
"db:clear-migrations": "rm -rf server/migrations",
|
||||
"build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs",
|
||||
"build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs",
|
||||
"start": "DB_TYPE=sqlite NODE_OPTIONS=--enable-source-maps NODE_ENV=development ENVIRONMENT=prod sh -c 'node dist/migrations.mjs && node dist/server.mjs'",
|
||||
"start": "ENVIRONMENT=prod node dist/migrations.mjs && ENVIRONMENT=prod NODE_ENV=development node --enable-source-maps dist/server.mjs",
|
||||
"email": "email dev --dir server/emails/templates --port 3005",
|
||||
"build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "^7.3.4",
|
||||
"@hookform/resolvers": "3.9.1",
|
||||
"@hookform/resolvers": "4.1.3",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@oslojs/crypto": "1.0.1",
|
||||
"@oslojs/encoding": "1.1.0",
|
||||
|
|
@ -49,15 +49,15 @@
|
|||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-toast": "1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-email/components": "0.5.0",
|
||||
"@react-email/components": "0.5.3",
|
||||
"@react-email/render": "^1.2.0",
|
||||
"@react-email/tailwind": "1.2.2",
|
||||
"@simplewebauthn/browser": "^13.1.0",
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"@simplewebauthn/server": "^9.0.3",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"arctic": "^3.7.0",
|
||||
"axios": "1.11.0",
|
||||
"axios": "^1.12.2",
|
||||
"better-sqlite3": "11.7.0",
|
||||
"canvas-confetti": "1.9.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
|
@ -68,11 +68,11 @@
|
|||
"cookies": "^0.9.1",
|
||||
"cors": "2.8.5",
|
||||
"crypto-js": "^4.2.0",
|
||||
"drizzle-orm": "0.44.4",
|
||||
"eslint": "9.33.0",
|
||||
"eslint-config-next": "15.4.6",
|
||||
"drizzle-orm": "0.44.5",
|
||||
"eslint": "9.35.0",
|
||||
"eslint-config-next": "15.5.3",
|
||||
"express": "5.1.0",
|
||||
"express-rate-limit": "8.0.1",
|
||||
"express-rate-limit": "8.1.0",
|
||||
"glob": "11.0.3",
|
||||
"helmet": "8.1.0",
|
||||
"http-errors": "2.0.0",
|
||||
|
|
@ -81,30 +81,30 @@
|
|||
"jmespath": "^0.16.0",
|
||||
"js-yaml": "4.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "0.539.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"moment": "2.30.1",
|
||||
"next": "15.4.6",
|
||||
"next-intl": "^4.3.4",
|
||||
"next": "15.5.3",
|
||||
"next-intl": "^4.3.9",
|
||||
"next-themes": "0.4.6",
|
||||
"node-cache": "5.1.2",
|
||||
"node-fetch": "3.3.2",
|
||||
"nodemailer": "7.0.5",
|
||||
"npm": "^11.5.2",
|
||||
"nodemailer": "7.0.6",
|
||||
"npm": "^11.6.0",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "^8.16.2",
|
||||
"posthog-node": "^5.7.0",
|
||||
"posthog-node": "^5.8.4",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"react-easy-sort": "^1.6.0",
|
||||
"react-easy-sort": "^1.7.0",
|
||||
"react-hook-form": "7.62.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"rebuild": "0.1.2",
|
||||
"semver": "^7.7.2",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tailwind-merge": "3.3.1",
|
||||
"tw-animate-css": "^1.3.7",
|
||||
"uuid": "^11.1.0",
|
||||
"tw-animate-css": "^1.3.8",
|
||||
"uuid": "^13.0.0",
|
||||
"vaul": "1.1.2",
|
||||
"winston": "3.17.0",
|
||||
"winston-daily-rotate-file": "5.0.0",
|
||||
|
|
@ -114,9 +114,9 @@
|
|||
"zod-validation-error": "3.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "1.49.0",
|
||||
"@dotenvx/dotenvx": "1.49.1",
|
||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@types/better-sqlite3": "7.6.12",
|
||||
"@types/cookie-parser": "1.4.9",
|
||||
"@types/cors": "2.8.19",
|
||||
|
|
@ -126,25 +126,25 @@
|
|||
"@types/jmespath": "^0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24",
|
||||
"@types/nodemailer": "6.4.17",
|
||||
"@types/node": "24.5.2",
|
||||
"@types/nodemailer": "7.0.1",
|
||||
"@types/pg": "8.15.5",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react": "19.1.13",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/yargs": "17.0.33",
|
||||
"drizzle-kit": "0.31.4",
|
||||
"esbuild": "0.25.9",
|
||||
"esbuild": "0.25.10",
|
||||
"esbuild-node-externals": "1.18.0",
|
||||
"postcss": "^8",
|
||||
"react-email": "4.2.8",
|
||||
"react-email": "4.2.11",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsx": "4.20.5",
|
||||
"typescript": "^5",
|
||||
"typescript-eslint": "^8.40.0"
|
||||
"typescript-eslint": "^8.44.0"
|
||||
},
|
||||
"overrides": {
|
||||
"emblor": {
|
||||
|
|
|
|||
BIN
public/idp/azure.png
Normal file
BIN
public/idp/azure.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
BIN
public/idp/google.png
Normal file
BIN
public/idp/google.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
|
|
@ -100,7 +100,9 @@ export enum ActionsEnum {
|
|||
getApiKey = "getApiKey",
|
||||
createOrgDomain = "createOrgDomain",
|
||||
deleteOrgDomain = "deleteOrgDomain",
|
||||
restartOrgDomain = "restartOrgDomain"
|
||||
restartOrgDomain = "restartOrgDomain",
|
||||
updateOrgUser = "updateOrgUser",
|
||||
applyBlueprint = "applyBlueprint"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { join } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
import { db } from "@server/db";
|
||||
import { db, resources, siteResources } from "@server/db";
|
||||
import { exitNodes, sites } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { __DIRNAME } from "@server/lib/consts";
|
||||
|
|
@ -34,6 +34,44 @@ export async function getUniqueSiteName(orgId: string): Promise<string> {
|
|||
}
|
||||
}
|
||||
|
||||
export async function getUniqueResourceName(orgId: string): Promise<string> {
|
||||
let loops = 0;
|
||||
while (true) {
|
||||
if (loops > 100) {
|
||||
throw new Error("Could not generate a unique name");
|
||||
}
|
||||
|
||||
const name = generateName();
|
||||
const count = await db
|
||||
.select({ niceId: resources.niceId, orgId: resources.orgId })
|
||||
.from(resources)
|
||||
.where(and(eq(resources.niceId, name), eq(resources.orgId, orgId)));
|
||||
if (count.length === 0) {
|
||||
return name;
|
||||
}
|
||||
loops++;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUniqueSiteResourceName(orgId: string): Promise<string> {
|
||||
let loops = 0;
|
||||
while (true) {
|
||||
if (loops > 100) {
|
||||
throw new Error("Could not generate a unique name");
|
||||
}
|
||||
|
||||
const name = generateName();
|
||||
const count = await db
|
||||
.select({ niceId: siteResources.niceId, orgId: siteResources.orgId })
|
||||
.from(siteResources)
|
||||
.where(and(eq(siteResources.niceId, name), eq(siteResources.orgId, orgId)));
|
||||
if (count.length === 0) {
|
||||
return name;
|
||||
}
|
||||
loops++;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUniqueExitNodeEndpointName(): Promise<string> {
|
||||
let loops = 0;
|
||||
const count = await db
|
||||
|
|
|
|||
|
|
@ -50,3 +50,4 @@ function createDb() {
|
|||
|
||||
export const db = createDb();
|
||||
export default db;
|
||||
export type Transaction = Parameters<Parameters<typeof db["transaction"]>[0]>[0];
|
||||
|
|
@ -71,6 +71,7 @@ export const resources = pgTable("resources", {
|
|||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
subdomain: varchar("subdomain"),
|
||||
fullDomain: varchar("fullDomain"),
|
||||
|
|
@ -95,6 +96,7 @@ export const resources = pgTable("resources", {
|
|||
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
headers: text("headers"), // comma-separated list of headers to add to the request
|
||||
});
|
||||
|
||||
export const targets = pgTable("targets", {
|
||||
|
|
@ -113,7 +115,9 @@ export const targets = pgTable("targets", {
|
|||
method: varchar("method"),
|
||||
port: integer("port").notNull(),
|
||||
internalPort: integer("internalPort"),
|
||||
enabled: boolean("enabled").notNull().default(true)
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
path: text("path"),
|
||||
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
||||
});
|
||||
|
||||
export const exitNodes = pgTable("exitNodes", {
|
||||
|
|
@ -127,7 +131,8 @@ export const exitNodes = pgTable("exitNodes", {
|
|||
maxConnections: integer("maxConnections"),
|
||||
online: boolean("online").notNull().default(false),
|
||||
lastPing: integer("lastPing"),
|
||||
type: text("type").default("gerbil") // gerbil, remoteExitNode
|
||||
type: text("type").default("gerbil"), // gerbil, remoteExitNode
|
||||
region: varchar("region")
|
||||
});
|
||||
|
||||
export const siteResources = pgTable("siteResources", { // this is for the clients
|
||||
|
|
@ -138,6 +143,7 @@ export const siteResources = pgTable("siteResources", { // this is for the clien
|
|||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
niceId: varchar("niceId").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
protocol: varchar("protocol").notNull(),
|
||||
proxyPort: integer("proxyPort").notNull(),
|
||||
|
|
@ -212,7 +218,8 @@ export const userOrgs = pgTable("userOrgs", {
|
|||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId),
|
||||
isOwner: boolean("isOwner").notNull().default(false)
|
||||
isOwner: boolean("isOwner").notNull().default(false),
|
||||
autoProvisioned: boolean("autoProvisioned").default(false)
|
||||
});
|
||||
|
||||
export const emailVerificationCodes = pgTable("emailVerificationCodes", {
|
||||
|
|
@ -458,6 +465,7 @@ export const idpOidcConfig = pgTable("idpOidcConfig", {
|
|||
idpId: integer("idpId")
|
||||
.notNull()
|
||||
.references(() => idp.idpId, { onDelete: "cascade" }),
|
||||
variant: varchar("variant").notNull().default("oidc"),
|
||||
clientId: varchar("clientId").notNull(),
|
||||
clientSecret: varchar("clientSecret").notNull(),
|
||||
authUrl: varchar("authUrl").notNull(),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ function createDb() {
|
|||
|
||||
export const db = createDb();
|
||||
export default db;
|
||||
export type Transaction = Parameters<Parameters<typeof db["transaction"]>[0]>[0];
|
||||
|
||||
function checkFileExists(filePath: string): boolean {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ export const resources = sqliteTable("resources", {
|
|||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: text("name").notNull(),
|
||||
subdomain: text("subdomain"),
|
||||
fullDomain: text("fullDomain"),
|
||||
|
|
@ -107,6 +108,7 @@ export const resources = sqliteTable("resources", {
|
|||
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
headers: text("headers"), // comma-separated list of headers to add to the request
|
||||
});
|
||||
|
||||
export const targets = sqliteTable("targets", {
|
||||
|
|
@ -125,7 +127,9 @@ export const targets = sqliteTable("targets", {
|
|||
method: text("method"),
|
||||
port: integer("port").notNull(),
|
||||
internalPort: integer("internalPort"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
path: text("path"),
|
||||
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
||||
});
|
||||
|
||||
export const exitNodes = sqliteTable("exitNodes", {
|
||||
|
|
@ -139,23 +143,28 @@ export const exitNodes = sqliteTable("exitNodes", {
|
|||
maxConnections: integer("maxConnections"),
|
||||
online: integer("online", { mode: "boolean" }).notNull().default(false),
|
||||
lastPing: integer("lastPing"),
|
||||
type: text("type").default("gerbil") // gerbil, remoteExitNode
|
||||
type: text("type").default("gerbil"), // gerbil, remoteExitNode
|
||||
region: text("region")
|
||||
});
|
||||
|
||||
export const siteResources = sqliteTable("siteResources", { // this is for the clients
|
||||
siteResourceId: integer("siteResourceId").primaryKey({ autoIncrement: true }),
|
||||
export const siteResources = sqliteTable("siteResources", {
|
||||
// this is for the clients
|
||||
siteResourceId: integer("siteResourceId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, { onDelete: "cascade" }),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: text("name").notNull(),
|
||||
protocol: text("protocol").notNull(),
|
||||
proxyPort: integer("proxyPort").notNull(),
|
||||
destinationPort: integer("destinationPort").notNull(),
|
||||
destinationIp: text("destinationIp").notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
|
||||
});
|
||||
|
||||
export const users = sqliteTable("user", {
|
||||
|
|
@ -259,7 +268,9 @@ export const clientSites = sqliteTable("clientSites", {
|
|||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, { onDelete: "cascade" }),
|
||||
isRelayed: integer("isRelayed", { mode: "boolean" }).notNull().default(false),
|
||||
isRelayed: integer("isRelayed", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
endpoint: text("endpoint")
|
||||
});
|
||||
|
||||
|
|
@ -317,7 +328,10 @@ export const userOrgs = sqliteTable("userOrgs", {
|
|||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId),
|
||||
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false)
|
||||
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
|
||||
autoProvisioned: integer("autoProvisioned", {
|
||||
mode: "boolean"
|
||||
}).default(false)
|
||||
});
|
||||
|
||||
export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
|
||||
|
|
@ -603,6 +617,7 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", {
|
|||
idpOauthConfigId: integer("idpOauthConfigId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
variant: text("variant").notNull().default("oidc"),
|
||||
idpId: integer("idpId")
|
||||
.notNull()
|
||||
.references(() => idp.idpId, { onDelete: "cascade" }),
|
||||
|
|
|
|||
170
server/lib/blueprints/applyBlueprint.ts
Normal file
170
server/lib/blueprints/applyBlueprint.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { db, newts, Target } from "@server/db";
|
||||
import { Config, ConfigSchema } from "./types";
|
||||
import { ProxyResourcesResults, updateProxyResources } from "./proxyResources";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { resources, targets, sites } from "@server/db";
|
||||
import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm";
|
||||
import { addTargets as addProxyTargets } from "@server/routers/newt/targets";
|
||||
import { addTargets as addClientTargets } from "@server/routers/client/targets";
|
||||
import {
|
||||
ClientResourcesResults,
|
||||
updateClientResources
|
||||
} from "./clientResources";
|
||||
|
||||
export async function applyBlueprint(
|
||||
orgId: string,
|
||||
configData: unknown,
|
||||
siteId?: number
|
||||
): Promise<void> {
|
||||
// Validate the input data
|
||||
const validationResult = ConfigSchema.safeParse(configData);
|
||||
if (!validationResult.success) {
|
||||
throw new Error(fromError(validationResult.error).toString());
|
||||
}
|
||||
|
||||
const config: Config = validationResult.data;
|
||||
|
||||
try {
|
||||
let proxyResourcesResults: ProxyResourcesResults = [];
|
||||
let clientResourcesResults: ClientResourcesResults = [];
|
||||
await db.transaction(async (trx) => {
|
||||
proxyResourcesResults = await updateProxyResources(
|
||||
orgId,
|
||||
config,
|
||||
trx,
|
||||
siteId
|
||||
);
|
||||
clientResourcesResults = await updateClientResources(
|
||||
orgId,
|
||||
config,
|
||||
trx,
|
||||
siteId
|
||||
);
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`Successfully updated proxy resources for org ${orgId}: ${JSON.stringify(proxyResourcesResults)}`
|
||||
);
|
||||
|
||||
// We need to update the targets on the newts from the successfully updated information
|
||||
for (const result of proxyResourcesResults) {
|
||||
for (const target of result.targetsToUpdate) {
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.innerJoin(newts, eq(sites.siteId, newts.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(sites.siteId, target.siteId),
|
||||
eq(sites.orgId, orgId),
|
||||
eq(sites.type, "newt"),
|
||||
isNotNull(sites.pubKey)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (site) {
|
||||
logger.debug(
|
||||
`Updating target ${target.targetId} on site ${site.sites.siteId}`
|
||||
);
|
||||
|
||||
await addProxyTargets(
|
||||
site.newt.newtId,
|
||||
[target],
|
||||
result.proxyResource.protocol,
|
||||
result.proxyResource.proxyPort
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Successfully updated client resources for org ${orgId}: ${JSON.stringify(clientResourcesResults)}`
|
||||
);
|
||||
|
||||
// We need to update the targets on the newts from the successfully updated information
|
||||
for (const result of clientResourcesResults) {
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.innerJoin(newts, eq(sites.siteId, newts.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(sites.siteId, result.resource.siteId),
|
||||
eq(sites.orgId, orgId),
|
||||
eq(sites.type, "newt"),
|
||||
isNotNull(sites.pubKey)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (site) {
|
||||
logger.debug(
|
||||
`Updating client resource ${result.resource.siteResourceId} on site ${site.sites.siteId}`
|
||||
);
|
||||
|
||||
await addClientTargets(
|
||||
site.newt.newtId,
|
||||
result.resource.destinationIp,
|
||||
result.resource.destinationPort,
|
||||
result.resource.protocol,
|
||||
result.resource.proxyPort
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update database from config: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// await updateDatabaseFromConfig("org_i21aifypnlyxur2", {
|
||||
// resources: {
|
||||
// "resource-nice-id": {
|
||||
// name: "this is my resource",
|
||||
// protocol: "http",
|
||||
// "full-domain": "level1.test.example.com",
|
||||
// "host-header": "example.com",
|
||||
// "tls-server-name": "example.com",
|
||||
// auth: {
|
||||
// pincode: 123456,
|
||||
// password: "sadfasdfadsf",
|
||||
// "sso-enabled": true,
|
||||
// "sso-roles": ["Member"],
|
||||
// "sso-users": ["owen@fossorial.io"],
|
||||
// "whitelist-users": ["owen@fossorial.io"]
|
||||
// },
|
||||
// targets: [
|
||||
// {
|
||||
// site: "glossy-plains-viscacha-rat",
|
||||
// hostname: "localhost",
|
||||
// method: "http",
|
||||
// port: 8000,
|
||||
// healthcheck: {
|
||||
// port: 8000,
|
||||
// hostname: "localhost"
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// site: "glossy-plains-viscacha-rat",
|
||||
// hostname: "localhost",
|
||||
// method: "http",
|
||||
// port: 8001
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
// "resource-nice-id2": {
|
||||
// name: "http server",
|
||||
// protocol: "tcp",
|
||||
// "proxy-port": 3000,
|
||||
// targets: [
|
||||
// {
|
||||
// site: "glossy-plains-viscacha-rat",
|
||||
// hostname: "localhost",
|
||||
// port: 3000,
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
53
server/lib/blueprints/applyNewtDockerBlueprint.ts
Normal file
53
server/lib/blueprints/applyNewtDockerBlueprint.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { sendToClient } from "@server/routers/ws";
|
||||
import { processContainerLabels } from "./parseDockerContainers";
|
||||
import { applyBlueprint } from "./applyBlueprint";
|
||||
import { db, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export async function applyNewtDockerBlueprint(
|
||||
siteId: number,
|
||||
newtId: string,
|
||||
containers: any
|
||||
) {
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
logger.warn("Site not found in applyNewtDockerBlueprint");
|
||||
return;
|
||||
}
|
||||
|
||||
// logger.debug(`Applying Docker blueprint to site: ${siteId}`);
|
||||
// logger.debug(`Containers: ${JSON.stringify(containers, null, 2)}`);
|
||||
|
||||
try {
|
||||
const blueprint = processContainerLabels(containers);
|
||||
|
||||
logger.debug(`Received Docker blueprint: ${JSON.stringify(blueprint)}`);
|
||||
|
||||
// Update the blueprint in the database
|
||||
await applyBlueprint(site.orgId, blueprint, site.siteId);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update database from config: ${error}`);
|
||||
await sendToClient(newtId, {
|
||||
type: "newt/blueprint/results",
|
||||
data: {
|
||||
success: false,
|
||||
message: `Failed to update database from config: ${error}`
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await sendToClient(newtId, {
|
||||
type: "newt/blueprint/results",
|
||||
data: {
|
||||
success: true,
|
||||
message: "Config updated successfully"
|
||||
}
|
||||
});
|
||||
}
|
||||
117
server/lib/blueprints/clientResources.ts
Normal file
117
server/lib/blueprints/clientResources.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import {
|
||||
SiteResource,
|
||||
siteResources,
|
||||
Transaction,
|
||||
} from "@server/db";
|
||||
import { sites } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import {
|
||||
Config,
|
||||
} from "./types";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export type ClientResourcesResults = {
|
||||
resource: SiteResource;
|
||||
}[];
|
||||
|
||||
export async function updateClientResources(
|
||||
orgId: string,
|
||||
config: Config,
|
||||
trx: Transaction,
|
||||
siteId?: number
|
||||
): Promise<ClientResourcesResults> {
|
||||
const results: ClientResourcesResults = [];
|
||||
|
||||
for (const [resourceNiceId, resourceData] of Object.entries(
|
||||
config["client-resources"]
|
||||
)) {
|
||||
const [existingResource] = await trx
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.niceId, resourceNiceId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const resourceSiteId = resourceData.site;
|
||||
let site;
|
||||
|
||||
if (resourceSiteId) {
|
||||
// Look up site by niceId
|
||||
[site] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.niceId, resourceSiteId),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
} else if (siteId) {
|
||||
// Use the provided siteId directly, but verify it belongs to the org
|
||||
[site] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
} else {
|
||||
throw new Error(`Target site is required`);
|
||||
}
|
||||
|
||||
if (!site) {
|
||||
throw new Error(
|
||||
`Site not found: ${resourceSiteId} in org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
if (existingResource) {
|
||||
// Update existing resource
|
||||
const [updatedResource] = await trx
|
||||
.update(siteResources)
|
||||
.set({
|
||||
name: resourceData.name || resourceNiceId,
|
||||
siteId: site.siteId,
|
||||
proxyPort: resourceData["proxy-port"]!,
|
||||
destinationIp: resourceData.hostname,
|
||||
destinationPort: resourceData["internal-port"],
|
||||
protocol: resourceData.protocol
|
||||
})
|
||||
.where(
|
||||
eq(
|
||||
siteResources.siteResourceId,
|
||||
existingResource.siteResourceId
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
results.push({ resource: updatedResource });
|
||||
} else {
|
||||
// Create new resource
|
||||
const [newResource] = await trx
|
||||
.insert(siteResources)
|
||||
.values({
|
||||
orgId: orgId,
|
||||
siteId: site.siteId,
|
||||
niceId: resourceNiceId,
|
||||
name: resourceData.name || resourceNiceId,
|
||||
proxyPort: resourceData["proxy-port"]!,
|
||||
destinationIp: resourceData.hostname,
|
||||
destinationPort: resourceData["internal-port"],
|
||||
protocol: resourceData.protocol
|
||||
})
|
||||
.returning();
|
||||
|
||||
logger.info(
|
||||
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
|
||||
);
|
||||
|
||||
results.push({ resource: newResource });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
301
server/lib/blueprints/parseDockerContainers.ts
Normal file
301
server/lib/blueprints/parseDockerContainers.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import logger from "@server/logger";
|
||||
import { setNestedProperty } from "./parseDotNotation";
|
||||
|
||||
export type DockerLabels = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
export type ParsedObject = {
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
type ContainerPort = {
|
||||
privatePort: number;
|
||||
publicPort: number;
|
||||
type: string;
|
||||
ip: string;
|
||||
};
|
||||
|
||||
type Container = {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
state: string;
|
||||
status: string;
|
||||
ports: ContainerPort[] | null;
|
||||
labels: DockerLabels;
|
||||
created: number;
|
||||
networks: { [key: string]: any };
|
||||
hostname: string;
|
||||
};
|
||||
|
||||
type Target = {
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
method?: string;
|
||||
enabled?: boolean;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
type ResourceConfig = {
|
||||
[key: string]: any;
|
||||
targets?: (Target | null)[];
|
||||
};
|
||||
|
||||
function getContainerPort(container: Container): number | null {
|
||||
if (!container.ports || container.ports.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// Return the first port's privatePort
|
||||
return container.ports[0].privatePort;
|
||||
// return container.ports[0].publicPort;
|
||||
}
|
||||
|
||||
export function processContainerLabels(containers: Container[]): {
|
||||
"proxy-resources": { [key: string]: ResourceConfig };
|
||||
"client-resources": { [key: string]: ResourceConfig };
|
||||
} {
|
||||
const result = {
|
||||
"proxy-resources": {} as { [key: string]: ResourceConfig },
|
||||
"client-resources": {} as { [key: string]: ResourceConfig }
|
||||
};
|
||||
|
||||
// Process each container
|
||||
containers.forEach((container) => {
|
||||
if (container.state !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
const proxyResourceLabels: DockerLabels = {};
|
||||
const clientResourceLabels: DockerLabels = {};
|
||||
|
||||
// Filter and separate proxy-resources and client-resources labels
|
||||
Object.entries(container.labels).forEach(([key, value]) => {
|
||||
if (key.startsWith("pangolin.proxy-resources.")) {
|
||||
// remove the pangolin.proxy- prefix to get "resources.xxx"
|
||||
const strippedKey = key.replace("pangolin.proxy-", "");
|
||||
proxyResourceLabels[strippedKey] = value;
|
||||
} else if (key.startsWith("pangolin.client-resources.")) {
|
||||
// remove the pangolin.client- prefix to get "resources.xxx"
|
||||
const strippedKey = key.replace("pangolin.client-", "");
|
||||
clientResourceLabels[strippedKey] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Process proxy resources
|
||||
if (Object.keys(proxyResourceLabels).length > 0) {
|
||||
processResourceLabels(proxyResourceLabels, container, result["proxy-resources"]);
|
||||
}
|
||||
|
||||
// Process client resources
|
||||
if (Object.keys(clientResourceLabels).length > 0) {
|
||||
processResourceLabels(clientResourceLabels, container, result["client-resources"]);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function processResourceLabels(
|
||||
resourceLabels: DockerLabels,
|
||||
container: Container,
|
||||
targetResult: { [key: string]: ResourceConfig }
|
||||
) {
|
||||
// Parse the labels using the existing parseDockerLabels logic
|
||||
const tempResult: ParsedObject = {};
|
||||
Object.entries(resourceLabels).forEach(([key, value]) => {
|
||||
setNestedProperty(tempResult, key, value);
|
||||
});
|
||||
|
||||
// Merge into target result
|
||||
if (tempResult.resources) {
|
||||
Object.entries(tempResult.resources).forEach(
|
||||
([resourceKey, resourceConfig]: [string, any]) => {
|
||||
// Initialize resource if it doesn't exist
|
||||
if (!targetResult[resourceKey]) {
|
||||
targetResult[resourceKey] = {};
|
||||
}
|
||||
|
||||
// Merge all properties except targets
|
||||
Object.entries(resourceConfig).forEach(
|
||||
([propKey, propValue]) => {
|
||||
if (propKey !== "targets") {
|
||||
targetResult[resourceKey][propKey] = propValue;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Handle targets specially
|
||||
if (
|
||||
resourceConfig.targets &&
|
||||
Array.isArray(resourceConfig.targets)
|
||||
) {
|
||||
const resource = targetResult[resourceKey];
|
||||
if (resource) {
|
||||
if (!resource.targets) {
|
||||
resource.targets = [];
|
||||
}
|
||||
|
||||
resourceConfig.targets.forEach(
|
||||
(target: any, targetIndex: number) => {
|
||||
// check if the target is an empty object
|
||||
if (
|
||||
typeof target === "object" &&
|
||||
Object.keys(target).length === 0
|
||||
) {
|
||||
logger.debug(
|
||||
`Skipping null target at index ${targetIndex} for resource ${resourceKey}`
|
||||
);
|
||||
resource.targets!.push(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure targets array is long enough
|
||||
while (
|
||||
resource.targets!.length <= targetIndex
|
||||
) {
|
||||
resource.targets!.push({});
|
||||
}
|
||||
|
||||
// Set default hostname and port if not provided
|
||||
const finalTarget = { ...target };
|
||||
if (!finalTarget.hostname) {
|
||||
finalTarget.hostname =
|
||||
container.name ||
|
||||
container.hostname;
|
||||
}
|
||||
if (!finalTarget.port) {
|
||||
const containerPort =
|
||||
getContainerPort(container);
|
||||
if (containerPort !== null) {
|
||||
finalTarget.port = containerPort;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge with existing target data
|
||||
resource.targets![targetIndex] = {
|
||||
...resource.targets![targetIndex],
|
||||
...finalTarget
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// // Test example
|
||||
// const testContainers: Container[] = [
|
||||
// {
|
||||
// id: "57e056cb0e3a",
|
||||
// name: "nginx1",
|
||||
// image: "nginxdemos/hello",
|
||||
// state: "running",
|
||||
// status: "Up 4 days",
|
||||
// ports: [
|
||||
// {
|
||||
// privatePort: 80,
|
||||
// publicPort: 8000,
|
||||
// type: "tcp",
|
||||
// ip: "0.0.0.0"
|
||||
// }
|
||||
// ],
|
||||
// labels: {
|
||||
// "resources.nginx.name": "nginx",
|
||||
// "resources.nginx.full-domain": "nginx.example.com",
|
||||
// "resources.nginx.protocol": "http",
|
||||
// "resources.nginx.targets[0].enabled": "true"
|
||||
// },
|
||||
// created: 1756942725,
|
||||
// networks: {
|
||||
// owen_default: {
|
||||
// networkId:
|
||||
// "cb131c0f1d5d8ef7158660e77fc370508f5a563e1f9829b53a1945ae3725b58c"
|
||||
// }
|
||||
// },
|
||||
// hostname: "57e056cb0e3a"
|
||||
// },
|
||||
// {
|
||||
// id: "58e056cb0e3b",
|
||||
// name: "nginx2",
|
||||
// image: "nginxdemos/hello",
|
||||
// state: "running",
|
||||
// status: "Up 4 days",
|
||||
// ports: [
|
||||
// {
|
||||
// privatePort: 80,
|
||||
// publicPort: 8001,
|
||||
// type: "tcp",
|
||||
// ip: "0.0.0.0"
|
||||
// }
|
||||
// ],
|
||||
// labels: {
|
||||
// "resources.nginx.name": "nginx",
|
||||
// "resources.nginx.full-domain": "nginx.example.com",
|
||||
// "resources.nginx.protocol": "http",
|
||||
// "resources.nginx.targets[1].enabled": "true"
|
||||
// },
|
||||
// created: 1756942726,
|
||||
// networks: {
|
||||
// owen_default: {
|
||||
// networkId:
|
||||
// "cb131c0f1d5d8ef7158660e77fc370508f5a563e1f9829b53a1945ae3725b58c"
|
||||
// }
|
||||
// },
|
||||
// hostname: "58e056cb0e3b"
|
||||
// },
|
||||
// {
|
||||
// id: "59e056cb0e3c",
|
||||
// name: "api-server",
|
||||
// image: "my-api:latest",
|
||||
// state: "running",
|
||||
// status: "Up 2 days",
|
||||
// ports: [
|
||||
// {
|
||||
// privatePort: 3000,
|
||||
// publicPort: 3000,
|
||||
// type: "tcp",
|
||||
// ip: "0.0.0.0"
|
||||
// }
|
||||
// ],
|
||||
// labels: {
|
||||
// "resources.api.name": "API Server",
|
||||
// "resources.api.protocol": "http",
|
||||
// "resources.api.targets[0].enabled": "true",
|
||||
// "resources.api.targets[0].hostname": "custom-host",
|
||||
// "resources.api.targets[0].port": "3001"
|
||||
// },
|
||||
// created: 1756942727,
|
||||
// networks: {
|
||||
// owen_default: {
|
||||
// networkId:
|
||||
// "cb131c0f1d5d8ef7158660e77fc370508f5a563e1f9829b53a1945ae3725b58c"
|
||||
// }
|
||||
// },
|
||||
// hostname: "59e056cb0e3c"
|
||||
// },
|
||||
// {
|
||||
// id: "d0e29b08361c",
|
||||
// name: "beautiful_wilson",
|
||||
// image: "bolkedebruin/rdpgw:latest",
|
||||
// state: "exited",
|
||||
// status: "Exited (0) 4 hours ago",
|
||||
// ports: null,
|
||||
// labels: {},
|
||||
// created: 1757359039,
|
||||
// networks: {
|
||||
// bridge: {
|
||||
// networkId:
|
||||
// "ea7f56dfc9cc476b8a3560b5b570d0fe8a6a2bc5e8343ab1ed37822086e89687"
|
||||
// }
|
||||
// },
|
||||
// hostname: "d0e29b08361c"
|
||||
// }
|
||||
// ];
|
||||
|
||||
// // Test the function
|
||||
// const result = processContainerLabels(testContainers);
|
||||
// console.log("Processed result:");
|
||||
// console.log(JSON.stringify(result, null, 2));
|
||||
109
server/lib/blueprints/parseDotNotation.ts
Normal file
109
server/lib/blueprints/parseDotNotation.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
export function setNestedProperty(obj: any, path: string, value: string): void {
|
||||
const keys = path.split(".");
|
||||
let current = obj;
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i];
|
||||
|
||||
// Handle array notation like "targets[0]"
|
||||
const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
|
||||
|
||||
if (arrayMatch) {
|
||||
const [, arrayKey, indexStr] = arrayMatch;
|
||||
const index = parseInt(indexStr, 10);
|
||||
|
||||
// Initialize array if it doesn't exist
|
||||
if (!current[arrayKey]) {
|
||||
current[arrayKey] = [];
|
||||
}
|
||||
|
||||
// Ensure array is long enough
|
||||
while (current[arrayKey].length <= index) {
|
||||
current[arrayKey].push({});
|
||||
}
|
||||
|
||||
current = current[arrayKey][index];
|
||||
} else {
|
||||
// Regular object property
|
||||
if (!current[key]) {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Set the final value
|
||||
const finalKey = keys[keys.length - 1];
|
||||
const arrayMatch = finalKey.match(/^(.+)\[(\d+)\]$/);
|
||||
|
||||
if (arrayMatch) {
|
||||
const [, arrayKey, indexStr] = arrayMatch;
|
||||
const index = parseInt(indexStr, 10);
|
||||
|
||||
if (!current[arrayKey]) {
|
||||
current[arrayKey] = [];
|
||||
}
|
||||
|
||||
// Ensure array is long enough
|
||||
while (current[arrayKey].length <= index) {
|
||||
current[arrayKey].push(null);
|
||||
}
|
||||
|
||||
current[arrayKey][index] = convertValue(value);
|
||||
} else {
|
||||
current[finalKey] = convertValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to convert string values to appropriate types
|
||||
export function convertValue(value: string): any {
|
||||
// Convert boolean strings
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
|
||||
// Convert numeric strings
|
||||
if (/^\d+$/.test(value)) {
|
||||
const num = parseInt(value, 10);
|
||||
return num;
|
||||
}
|
||||
|
||||
if (/^\d*\.\d+$/.test(value)) {
|
||||
const num = parseFloat(value);
|
||||
return num;
|
||||
}
|
||||
|
||||
// Return as string
|
||||
return value;
|
||||
}
|
||||
|
||||
// // Example usage:
|
||||
// const dockerLabels: DockerLabels = {
|
||||
// "resources.resource-nice-id.name": "this is my resource",
|
||||
// "resources.resource-nice-id.protocol": "http",
|
||||
// "resources.resource-nice-id.full-domain": "level1.test3.example.com",
|
||||
// "resources.resource-nice-id.host-header": "example.com",
|
||||
// "resources.resource-nice-id.tls-server-name": "example.com",
|
||||
// "resources.resource-nice-id.auth.pincode": "123456",
|
||||
// "resources.resource-nice-id.auth.password": "sadfasdfadsf",
|
||||
// "resources.resource-nice-id.auth.sso-enabled": "true",
|
||||
// "resources.resource-nice-id.auth.sso-roles[0]": "Member",
|
||||
// "resources.resource-nice-id.auth.sso-users[0]": "owen@fossorial.io",
|
||||
// "resources.resource-nice-id.auth.whitelist-users[0]": "owen@fossorial.io",
|
||||
// "resources.resource-nice-id.targets[0].hostname": "localhost",
|
||||
// "resources.resource-nice-id.targets[0].method": "http",
|
||||
// "resources.resource-nice-id.targets[0].port": "8000",
|
||||
// "resources.resource-nice-id.targets[0].healthcheck.port": "8000",
|
||||
// "resources.resource-nice-id.targets[0].healthcheck.hostname": "localhost",
|
||||
// "resources.resource-nice-id.targets[1].hostname": "localhost",
|
||||
// "resources.resource-nice-id.targets[1].method": "http",
|
||||
// "resources.resource-nice-id.targets[1].port": "8001",
|
||||
// "resources.resource-nice-id2.name": "this is other resource",
|
||||
// "resources.resource-nice-id2.protocol": "tcp",
|
||||
// "resources.resource-nice-id2.proxy-port": "3000",
|
||||
// "resources.resource-nice-id2.targets[0].hostname": "localhost",
|
||||
// "resources.resource-nice-id2.targets[0].port": "3000"
|
||||
// };
|
||||
|
||||
// // Parse the labels
|
||||
// const parsed = parseDockerLabels(dockerLabels);
|
||||
// console.log(JSON.stringify(parsed, null, 2));
|
||||
885
server/lib/blueprints/proxyResources.ts
Normal file
885
server/lib/blueprints/proxyResources.ts
Normal file
|
|
@ -0,0 +1,885 @@
|
|||
import {
|
||||
domains,
|
||||
orgDomains,
|
||||
Resource,
|
||||
resourcePincode,
|
||||
resourceRules,
|
||||
resourceWhitelist,
|
||||
roleResources,
|
||||
roles,
|
||||
Target,
|
||||
Transaction,
|
||||
userOrgs,
|
||||
userResources,
|
||||
users
|
||||
} from "@server/db";
|
||||
import { resources, targets, sites } from "@server/db";
|
||||
import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm";
|
||||
import {
|
||||
Config,
|
||||
ConfigSchema,
|
||||
isTargetsOnlyResource,
|
||||
TargetData
|
||||
} from "./types";
|
||||
import logger from "@server/logger";
|
||||
import { pickPort } from "@server/routers/target/helpers";
|
||||
import { resourcePassword } from "@server/db";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
||||
|
||||
export type ProxyResourcesResults = {
|
||||
proxyResource: Resource;
|
||||
targetsToUpdate: Target[];
|
||||
}[];
|
||||
|
||||
export async function updateProxyResources(
|
||||
orgId: string,
|
||||
config: Config,
|
||||
trx: Transaction,
|
||||
siteId?: number
|
||||
): Promise<ProxyResourcesResults> {
|
||||
const results: ProxyResourcesResults = [];
|
||||
|
||||
for (const [resourceNiceId, resourceData] of Object.entries(
|
||||
config["proxy-resources"]
|
||||
)) {
|
||||
const targetsToUpdate: Target[] = [];
|
||||
let resource: Resource;
|
||||
|
||||
async function createTarget( // reusable function to create a target
|
||||
resourceId: number,
|
||||
targetData: TargetData
|
||||
) {
|
||||
const targetSiteId = targetData.site;
|
||||
let site;
|
||||
|
||||
if (targetSiteId) {
|
||||
// Look up site by niceId
|
||||
[site] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.niceId, targetSiteId),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
} else if (siteId) {
|
||||
// Use the provided siteId directly, but verify it belongs to the org
|
||||
[site] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||
)
|
||||
.limit(1);
|
||||
} else {
|
||||
throw new Error(`Target site is required`);
|
||||
}
|
||||
|
||||
if (!site) {
|
||||
throw new Error(
|
||||
`Site not found: ${targetSiteId} in org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
let internalPortToCreate;
|
||||
if (!targetData["internal-port"]) {
|
||||
const { internalPort, targetIps } = await pickPort(
|
||||
site.siteId!,
|
||||
trx
|
||||
);
|
||||
internalPortToCreate = internalPort;
|
||||
} else {
|
||||
internalPortToCreate = targetData["internal-port"];
|
||||
}
|
||||
|
||||
// Create target
|
||||
const [newTarget] = await trx
|
||||
.insert(targets)
|
||||
.values({
|
||||
resourceId: resourceId,
|
||||
siteId: site.siteId,
|
||||
ip: targetData.hostname,
|
||||
method: targetData.method,
|
||||
port: targetData.port,
|
||||
enabled: targetData.enabled,
|
||||
internalPort: internalPortToCreate,
|
||||
path: targetData.path,
|
||||
pathMatchType: targetData["path-match"]
|
||||
})
|
||||
.returning();
|
||||
|
||||
targetsToUpdate.push(newTarget);
|
||||
}
|
||||
|
||||
// Find existing resource by niceId and orgId
|
||||
const [existingResource] = await trx
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.niceId, resourceNiceId),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const http = resourceData.protocol == "http";
|
||||
const protocol =
|
||||
resourceData.protocol == "http" ? "tcp" : resourceData.protocol;
|
||||
const resourceEnabled =
|
||||
resourceData.enabled == undefined || resourceData.enabled == null
|
||||
? true
|
||||
: resourceData.enabled;
|
||||
const resourceSsl =
|
||||
resourceData.ssl == undefined || resourceData.ssl == null
|
||||
? true
|
||||
: resourceData.ssl;
|
||||
let headers = "";
|
||||
for (const header of resourceData.headers || []) {
|
||||
headers += `${header.name}: ${header.value},`;
|
||||
}
|
||||
// if there are headers, remove the trailing comma
|
||||
if (headers.endsWith(",")) {
|
||||
headers = headers.slice(0, -1);
|
||||
}
|
||||
|
||||
if (existingResource) {
|
||||
let domain;
|
||||
if (http) {
|
||||
domain = await getDomain(
|
||||
existingResource.resourceId,
|
||||
resourceData["full-domain"]!,
|
||||
orgId,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
// check if the only key in the resource is targets, if so, skip the update
|
||||
if (isTargetsOnlyResource(resourceData)) {
|
||||
logger.debug(
|
||||
`Skipping update for resource ${existingResource.resourceId} as only targets are provided`
|
||||
);
|
||||
resource = existingResource;
|
||||
} else {
|
||||
// Update existing resource
|
||||
[resource] = await trx
|
||||
.update(resources)
|
||||
.set({
|
||||
name: resourceData.name || "Unnamed Resource",
|
||||
protocol: protocol || "http",
|
||||
http: http,
|
||||
proxyPort: http ? null : resourceData["proxy-port"],
|
||||
fullDomain: http ? resourceData["full-domain"] : null,
|
||||
subdomain: domain ? domain.subdomain : null,
|
||||
domainId: domain ? domain.domainId : null,
|
||||
enabled: resourceEnabled,
|
||||
sso: resourceData.auth?.["sso-enabled"] || false,
|
||||
ssl: resourceSsl,
|
||||
setHostHeader: resourceData["host-header"] || null,
|
||||
tlsServerName: resourceData["tls-server-name"] || null,
|
||||
emailWhitelistEnabled: resourceData.auth?.[
|
||||
"whitelist-users"
|
||||
]
|
||||
? resourceData.auth["whitelist-users"].length > 0
|
||||
: false,
|
||||
headers: headers || null,
|
||||
applyRules:
|
||||
resourceData.rules && resourceData.rules.length > 0
|
||||
})
|
||||
.where(
|
||||
eq(resources.resourceId, existingResource.resourceId)
|
||||
)
|
||||
.returning();
|
||||
|
||||
await trx
|
||||
.delete(resourcePassword)
|
||||
.where(
|
||||
eq(
|
||||
resourcePassword.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
);
|
||||
if (resourceData.auth?.password) {
|
||||
const passwordHash = await hashPassword(
|
||||
resourceData.auth.password
|
||||
);
|
||||
|
||||
await trx.insert(resourcePassword).values({
|
||||
resourceId: existingResource.resourceId,
|
||||
passwordHash
|
||||
});
|
||||
}
|
||||
|
||||
await trx
|
||||
.delete(resourcePincode)
|
||||
.where(
|
||||
eq(
|
||||
resourcePincode.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
);
|
||||
if (resourceData.auth?.pincode) {
|
||||
const pincodeHash = await hashPassword(
|
||||
resourceData.auth.pincode.toString()
|
||||
);
|
||||
|
||||
await trx.insert(resourcePincode).values({
|
||||
resourceId: existingResource.resourceId,
|
||||
pincodeHash,
|
||||
digitLength: 6
|
||||
});
|
||||
}
|
||||
|
||||
if (resourceData.auth?.["sso-roles"]) {
|
||||
const ssoRoles = resourceData.auth?.["sso-roles"];
|
||||
await syncRoleResources(
|
||||
existingResource.resourceId,
|
||||
ssoRoles,
|
||||
orgId,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
if (resourceData.auth?.["sso-users"]) {
|
||||
const ssoUsers = resourceData.auth?.["sso-users"];
|
||||
await syncUserResources(
|
||||
existingResource.resourceId,
|
||||
ssoUsers,
|
||||
orgId,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
if (resourceData.auth?.["whitelist-users"]) {
|
||||
const whitelistUsers =
|
||||
resourceData.auth?.["whitelist-users"];
|
||||
await syncWhitelistUsers(
|
||||
existingResource.resourceId,
|
||||
whitelistUsers,
|
||||
orgId,
|
||||
trx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existingResourceTargets = await trx
|
||||
.select()
|
||||
.from(targets)
|
||||
.where(eq(targets.resourceId, existingResource.resourceId))
|
||||
.orderBy(asc(targets.targetId));
|
||||
|
||||
// Create new targets
|
||||
for (const [index, targetData] of resourceData.targets.entries()) {
|
||||
if (
|
||||
!targetData ||
|
||||
(typeof targetData === "object" &&
|
||||
Object.keys(targetData).length === 0)
|
||||
) {
|
||||
// If targetData is null or an empty object, we can skip it
|
||||
continue;
|
||||
}
|
||||
const existingTarget = existingResourceTargets[index];
|
||||
|
||||
if (existingTarget) {
|
||||
const targetSiteId = targetData.site;
|
||||
let site;
|
||||
|
||||
if (targetSiteId) {
|
||||
// Look up site by niceId
|
||||
[site] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.niceId, targetSiteId),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
} else if (siteId) {
|
||||
// Use the provided siteId directly, but verify it belongs to the org
|
||||
[site] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.siteId, siteId),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
} else {
|
||||
throw new Error(`Target site is required`);
|
||||
}
|
||||
|
||||
if (!site) {
|
||||
throw new Error(
|
||||
`Site not found: ${targetSiteId} in org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
// update this target
|
||||
const [updatedTarget] = await trx
|
||||
.update(targets)
|
||||
.set({
|
||||
siteId: site.siteId,
|
||||
ip: targetData.hostname,
|
||||
method: http ? targetData.method : null,
|
||||
port: targetData.port,
|
||||
enabled: targetData.enabled,
|
||||
path: targetData.path,
|
||||
pathMatchType: targetData["path-match"]
|
||||
})
|
||||
.where(eq(targets.targetId, existingTarget.targetId))
|
||||
.returning();
|
||||
|
||||
if (checkIfTargetChanged(existingTarget, updatedTarget)) {
|
||||
let internalPortToUpdate;
|
||||
if (!targetData["internal-port"]) {
|
||||
const { internalPort, targetIps } = await pickPort(
|
||||
site.siteId!,
|
||||
trx
|
||||
);
|
||||
internalPortToUpdate = internalPort;
|
||||
} else {
|
||||
internalPortToUpdate = targetData["internal-port"];
|
||||
}
|
||||
|
||||
const [finalUpdatedTarget] = await trx // this double is so we can check the whole target before and after
|
||||
.update(targets)
|
||||
.set({
|
||||
internalPort: internalPortToUpdate
|
||||
})
|
||||
.where(
|
||||
eq(targets.targetId, existingTarget.targetId)
|
||||
)
|
||||
.returning();
|
||||
|
||||
targetsToUpdate.push(finalUpdatedTarget);
|
||||
}
|
||||
} else {
|
||||
await createTarget(existingResource.resourceId, targetData);
|
||||
}
|
||||
}
|
||||
|
||||
if (existingResourceTargets.length > resourceData.targets.length) {
|
||||
const targetsToDelete = existingResourceTargets.slice(
|
||||
resourceData.targets.length
|
||||
);
|
||||
logger.debug(
|
||||
`Targets to delete: ${JSON.stringify(targetsToDelete)}`
|
||||
);
|
||||
for (const target of targetsToDelete) {
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
if (siteId && target.siteId !== siteId) {
|
||||
logger.debug(
|
||||
`Skipping target ${target.targetId} for deletion. Site ID does not match filter.`
|
||||
);
|
||||
continue; // only delete targets for the specified siteId
|
||||
}
|
||||
logger.debug(`Deleting target ${target.targetId}`);
|
||||
await trx
|
||||
.delete(targets)
|
||||
.where(eq(targets.targetId, target.targetId));
|
||||
}
|
||||
}
|
||||
|
||||
const existingRules = await trx
|
||||
.select()
|
||||
.from(resourceRules)
|
||||
.where(
|
||||
eq(resourceRules.resourceId, existingResource.resourceId)
|
||||
)
|
||||
.orderBy(resourceRules.priority);
|
||||
|
||||
// Sync rules
|
||||
for (const [index, rule] of resourceData.rules?.entries() || []) {
|
||||
const existingRule = existingRules[index];
|
||||
if (existingRule) {
|
||||
if (
|
||||
existingRule.action !== getRuleAction(rule.action) ||
|
||||
existingRule.match !== rule.match.toUpperCase() ||
|
||||
existingRule.value !== rule.value
|
||||
) {
|
||||
validateRule(rule);
|
||||
await trx
|
||||
.update(resourceRules)
|
||||
.set({
|
||||
action: getRuleAction(rule.action),
|
||||
match: rule.match.toUpperCase(),
|
||||
value: rule.value
|
||||
})
|
||||
.where(
|
||||
eq(resourceRules.ruleId, existingRule.ruleId)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
validateRule(rule);
|
||||
await trx.insert(resourceRules).values({
|
||||
resourceId: existingResource.resourceId,
|
||||
action: getRuleAction(rule.action),
|
||||
match: rule.match.toUpperCase(),
|
||||
value: rule.value,
|
||||
priority: index + 1 // start priorities at 1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (existingRules.length > (resourceData.rules?.length || 0)) {
|
||||
const rulesToDelete = existingRules.slice(
|
||||
resourceData.rules?.length || 0
|
||||
);
|
||||
for (const rule of rulesToDelete) {
|
||||
await trx
|
||||
.delete(resourceRules)
|
||||
.where(eq(resourceRules.ruleId, rule.ruleId));
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`Updated resource ${existingResource.resourceId}`);
|
||||
} else {
|
||||
// create a brand new resource
|
||||
let domain;
|
||||
if (http) {
|
||||
domain = await getDomain(
|
||||
undefined,
|
||||
resourceData["full-domain"]!,
|
||||
orgId,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
// Create new resource
|
||||
const [newResource] = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
orgId,
|
||||
niceId: resourceNiceId,
|
||||
name: resourceData.name || "Unnamed Resource",
|
||||
protocol: resourceData.protocol || "http",
|
||||
http: http,
|
||||
proxyPort: http ? null : resourceData["proxy-port"],
|
||||
fullDomain: http ? resourceData["full-domain"] : null,
|
||||
subdomain: domain ? domain.subdomain : null,
|
||||
domainId: domain ? domain.domainId : null,
|
||||
enabled: resourceEnabled,
|
||||
sso: resourceData.auth?.["sso-enabled"] || false,
|
||||
setHostHeader: resourceData["host-header"] || null,
|
||||
tlsServerName: resourceData["tls-server-name"] || null,
|
||||
ssl: resourceSsl,
|
||||
headers: headers || null,
|
||||
applyRules:
|
||||
resourceData.rules && resourceData.rules.length > 0
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (resourceData.auth?.password) {
|
||||
const passwordHash = await hashPassword(
|
||||
resourceData.auth.password
|
||||
);
|
||||
|
||||
await trx.insert(resourcePassword).values({
|
||||
resourceId: newResource.resourceId,
|
||||
passwordHash
|
||||
});
|
||||
}
|
||||
|
||||
if (resourceData.auth?.pincode) {
|
||||
const pincodeHash = await hashPassword(
|
||||
resourceData.auth.pincode.toString()
|
||||
);
|
||||
|
||||
await trx.insert(resourcePincode).values({
|
||||
resourceId: newResource.resourceId,
|
||||
pincodeHash,
|
||||
digitLength: 6
|
||||
});
|
||||
}
|
||||
|
||||
resource = newResource;
|
||||
|
||||
const [adminRole] = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!adminRole) {
|
||||
throw new Error(`Admin role not found`);
|
||||
}
|
||||
|
||||
await trx.insert(roleResources).values({
|
||||
roleId: adminRole.roleId,
|
||||
resourceId: newResource.resourceId
|
||||
});
|
||||
|
||||
if (resourceData.auth?.["sso-roles"]) {
|
||||
const ssoRoles = resourceData.auth?.["sso-roles"];
|
||||
await syncRoleResources(
|
||||
newResource.resourceId,
|
||||
ssoRoles,
|
||||
orgId,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
if (resourceData.auth?.["sso-users"]) {
|
||||
const ssoUsers = resourceData.auth?.["sso-users"];
|
||||
await syncUserResources(
|
||||
newResource.resourceId,
|
||||
ssoUsers,
|
||||
orgId,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
if (resourceData.auth?.["whitelist-users"]) {
|
||||
const whitelistUsers = resourceData.auth?.["whitelist-users"];
|
||||
await syncWhitelistUsers(
|
||||
newResource.resourceId,
|
||||
whitelistUsers,
|
||||
orgId,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
// Create new targets
|
||||
for (const targetData of resourceData.targets) {
|
||||
if (!targetData) {
|
||||
// If targetData is null or an empty object, we can skip it
|
||||
continue;
|
||||
}
|
||||
await createTarget(newResource.resourceId, targetData);
|
||||
}
|
||||
|
||||
for (const [index, rule] of resourceData.rules?.entries() || []) {
|
||||
validateRule(rule);
|
||||
await trx.insert(resourceRules).values({
|
||||
resourceId: newResource.resourceId,
|
||||
action: getRuleAction(rule.action),
|
||||
match: rule.match.toUpperCase(),
|
||||
value: rule.value,
|
||||
priority: index + 1 // start priorities at 1
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug(`Created resource ${newResource.resourceId}`);
|
||||
}
|
||||
|
||||
results.push({
|
||||
proxyResource: resource,
|
||||
targetsToUpdate
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function getRuleAction(input: string) {
|
||||
let action = "DROP";
|
||||
if (input == "allow") {
|
||||
action = "ACCEPT";
|
||||
} else if (input == "deny") {
|
||||
action = "DROP";
|
||||
} else if (input == "pass") {
|
||||
action = "PASS";
|
||||
}
|
||||
return action;
|
||||
}
|
||||
|
||||
function validateRule(rule: any) {
|
||||
if (rule.match === "cidr") {
|
||||
if (!isValidCIDR(rule.value)) {
|
||||
throw new Error(`Invalid CIDR provided: ${rule.value}`);
|
||||
}
|
||||
} else if (rule.match === "ip") {
|
||||
if (!isValidIP(rule.value)) {
|
||||
throw new Error(`Invalid IP provided: ${rule.value}`);
|
||||
}
|
||||
} else if (rule.match === "path") {
|
||||
if (!isValidUrlGlobPattern(rule.value)) {
|
||||
throw new Error(`Invalid URL glob pattern: ${rule.value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncRoleResources(
|
||||
resourceId: number,
|
||||
ssoRoles: string[],
|
||||
orgId: string,
|
||||
trx: Transaction
|
||||
) {
|
||||
const existingRoleResources = await trx
|
||||
.select()
|
||||
.from(roleResources)
|
||||
.where(eq(roleResources.resourceId, resourceId));
|
||||
|
||||
for (const roleName of ssoRoles) {
|
||||
if (roleName === "Admin") {
|
||||
continue; // never add admin access
|
||||
}
|
||||
|
||||
const [role] = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!role) {
|
||||
throw new Error(`Role not found: ${roleName} in org ${orgId}`);
|
||||
}
|
||||
|
||||
const existingRoleResource = existingRoleResources.find(
|
||||
(rr) => rr.roleId === role.roleId
|
||||
);
|
||||
|
||||
if (!existingRoleResource) {
|
||||
await trx.insert(roleResources).values({
|
||||
roleId: role.roleId,
|
||||
resourceId: resourceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const existingRoleResource of existingRoleResources) {
|
||||
const [role] = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(eq(roles.roleId, existingRoleResource.roleId))
|
||||
.limit(1);
|
||||
|
||||
if (role.isAdmin) {
|
||||
continue; // never remove admin access
|
||||
}
|
||||
|
||||
if (role && !ssoRoles.includes(role.name)) {
|
||||
await trx
|
||||
.delete(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.roleId, existingRoleResource.roleId),
|
||||
eq(roleResources.resourceId, resourceId)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncUserResources(
|
||||
resourceId: number,
|
||||
ssoUsers: string[],
|
||||
orgId: string,
|
||||
trx: Transaction
|
||||
) {
|
||||
const existingUserResources = await trx
|
||||
.select()
|
||||
.from(userResources)
|
||||
.where(eq(userResources.resourceId, resourceId));
|
||||
|
||||
for (const email of ssoUsers) {
|
||||
const [user] = await trx
|
||||
.select()
|
||||
.from(users)
|
||||
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||
.where(and(eq(users.email, email), eq(userOrgs.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(`User not found: ${email} in org ${orgId}`);
|
||||
}
|
||||
|
||||
const existingUserResource = existingUserResources.find(
|
||||
(rr) => rr.userId === user.user.userId
|
||||
);
|
||||
|
||||
if (!existingUserResource) {
|
||||
await trx.insert(userResources).values({
|
||||
userId: user.user.userId,
|
||||
resourceId: resourceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const existingUserResource of existingUserResources) {
|
||||
const [user] = await trx
|
||||
.select()
|
||||
.from(users)
|
||||
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||
.where(
|
||||
and(
|
||||
eq(users.userId, existingUserResource.userId),
|
||||
eq(userOrgs.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (user && user.user.email && !ssoUsers.includes(user.user.email)) {
|
||||
await trx
|
||||
.delete(userResources)
|
||||
.where(
|
||||
and(
|
||||
eq(userResources.userId, existingUserResource.userId),
|
||||
eq(userResources.resourceId, resourceId)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncWhitelistUsers(
|
||||
resourceId: number,
|
||||
whitelistUsers: string[],
|
||||
orgId: string,
|
||||
trx: Transaction
|
||||
) {
|
||||
const existingWhitelist = await trx
|
||||
.select()
|
||||
.from(resourceWhitelist)
|
||||
.where(eq(resourceWhitelist.resourceId, resourceId));
|
||||
|
||||
for (const email of whitelistUsers) {
|
||||
const [user] = await trx
|
||||
.select()
|
||||
.from(users)
|
||||
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||
.where(and(eq(users.email, email), eq(userOrgs.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(`User not found: ${email} in org ${orgId}`);
|
||||
}
|
||||
|
||||
const existingWhitelistEntry = existingWhitelist.find(
|
||||
(w) => w.email === email
|
||||
);
|
||||
|
||||
if (!existingWhitelistEntry) {
|
||||
await trx.insert(resourceWhitelist).values({
|
||||
email,
|
||||
resourceId: resourceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const existingWhitelistEntry of existingWhitelist) {
|
||||
if (!whitelistUsers.includes(existingWhitelistEntry.email)) {
|
||||
await trx
|
||||
.delete(resourceWhitelist)
|
||||
.where(
|
||||
and(
|
||||
eq(resourceWhitelist.resourceId, resourceId),
|
||||
eq(
|
||||
resourceWhitelist.email,
|
||||
existingWhitelistEntry.email
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkIfTargetChanged(
|
||||
existing: Target | undefined,
|
||||
incoming: Target | undefined
|
||||
): boolean {
|
||||
if (!existing && incoming) return true;
|
||||
if (existing && !incoming) return true;
|
||||
if (!existing || !incoming) return false;
|
||||
|
||||
if (existing.ip !== incoming.ip) return true;
|
||||
if (existing.port !== incoming.port) return true;
|
||||
if (existing.siteId !== incoming.siteId) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function getDomain(
|
||||
resourceId: number | undefined,
|
||||
fullDomain: string,
|
||||
orgId: string,
|
||||
trx: Transaction
|
||||
) {
|
||||
const [fullDomainExists] = await trx
|
||||
.select({ resourceId: resources.resourceId })
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.fullDomain, fullDomain),
|
||||
eq(resources.orgId, orgId),
|
||||
resourceId
|
||||
? ne(resources.resourceId, resourceId)
|
||||
: isNotNull(resources.resourceId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (fullDomainExists) {
|
||||
throw new Error(
|
||||
`Resource already exists: ${fullDomain} in org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
const domain = await getDomainId(orgId, fullDomain, trx);
|
||||
|
||||
if (!domain) {
|
||||
throw new Error(
|
||||
`Domain not found for full-domain: ${fullDomain} in org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
return domain;
|
||||
}
|
||||
|
||||
async function getDomainId(
|
||||
orgId: string,
|
||||
fullDomain: string,
|
||||
trx: Transaction
|
||||
): Promise<{ subdomain: string | null; domainId: string } | null> {
|
||||
const possibleDomains = await trx
|
||||
.select()
|
||||
.from(domains)
|
||||
.innerJoin(orgDomains, eq(domains.domainId, orgDomains.domainId))
|
||||
.where(and(eq(orgDomains.orgId, orgId), eq(domains.verified, true)))
|
||||
.execute();
|
||||
|
||||
if (possibleDomains.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validDomains = possibleDomains.filter((domain) => {
|
||||
if (domain.domains.type == "ns" || domain.domains.type == "wildcard") {
|
||||
return (
|
||||
fullDomain === domain.domains.baseDomain ||
|
||||
fullDomain.endsWith(`.${domain.domains.baseDomain}`)
|
||||
);
|
||||
} else if (domain.domains.type == "cname") {
|
||||
return fullDomain === domain.domains.baseDomain;
|
||||
}
|
||||
});
|
||||
|
||||
if (validDomains.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const domainSelection = validDomains[0].domains;
|
||||
const baseDomain = domainSelection.baseDomain;
|
||||
|
||||
// remove the base domain of the domain
|
||||
let subdomain = null;
|
||||
if (domainSelection.type == "ns") {
|
||||
if (fullDomain != baseDomain) {
|
||||
subdomain = fullDomain.replace(`.${baseDomain}`, "");
|
||||
}
|
||||
}
|
||||
|
||||
// Return the first valid domain
|
||||
return {
|
||||
subdomain: subdomain,
|
||||
domainId: domainSelection.domainId
|
||||
};
|
||||
}
|
||||
366
server/lib/blueprints/types.ts
Normal file
366
server/lib/blueprints/types.ts
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const SiteSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
"docker-socket-enabled": z.boolean().optional().default(true)
|
||||
});
|
||||
|
||||
// Schema for individual target within a resource
|
||||
export const TargetSchema = z.object({
|
||||
site: z.string().optional(),
|
||||
method: z.enum(["http", "https", "h2c"]).optional(),
|
||||
hostname: z.string(),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
"internal-port": z.number().int().min(1).max(65535).optional(),
|
||||
path: z.string().optional(),
|
||||
"path-match": z.enum(["exact", "prefix", "regex"]).optional().nullable()
|
||||
});
|
||||
export type TargetData = z.infer<typeof TargetSchema>;
|
||||
|
||||
export const AuthSchema = z.object({
|
||||
// pincode has to have 6 digits
|
||||
pincode: z.number().min(100000).max(999999).optional(),
|
||||
password: z.string().min(1).optional(),
|
||||
"sso-enabled": z.boolean().optional().default(false),
|
||||
"sso-roles": z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.default([])
|
||||
.refine((roles) => !roles.includes("Admin"), {
|
||||
message: "Admin role cannot be included in sso-roles"
|
||||
}),
|
||||
"sso-users": z.array(z.string().email()).optional().default([]),
|
||||
"whitelist-users": z.array(z.string().email()).optional().default([]),
|
||||
});
|
||||
|
||||
export const RuleSchema = z.object({
|
||||
action: z.enum(["allow", "deny", "pass"]),
|
||||
match: z.enum(["cidr", "path", "ip", "country"]),
|
||||
value: z.string()
|
||||
});
|
||||
|
||||
export const HeaderSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
value: z.string().min(1)
|
||||
});
|
||||
|
||||
// Schema for individual resource
|
||||
export const ResourceSchema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
protocol: z.enum(["http", "tcp", "udp"]).optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
"full-domain": z.string().optional(),
|
||||
"proxy-port": z.number().int().min(1).max(65535).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
targets: z.array(TargetSchema.nullable()).optional().default([]),
|
||||
auth: AuthSchema.optional(),
|
||||
"host-header": z.string().optional(),
|
||||
"tls-server-name": z.string().optional(),
|
||||
headers: z.array(HeaderSchema).optional(),
|
||||
rules: z.array(RuleSchema).optional()
|
||||
})
|
||||
.refine(
|
||||
(resource) => {
|
||||
if (isTargetsOnlyResource(resource)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise, require name and protocol for full resource definition
|
||||
return (
|
||||
resource.name !== undefined && resource.protocol !== undefined
|
||||
);
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Resource must either be targets-only (only 'targets' field) or have both 'name' and 'protocol' fields at a minimum",
|
||||
path: ["name", "protocol"]
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(resource) => {
|
||||
if (isTargetsOnlyResource(resource)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If protocol is http, all targets must have method field
|
||||
if (resource.protocol === "http") {
|
||||
return resource.targets.every(
|
||||
(target) => target == null || target.method !== undefined
|
||||
);
|
||||
}
|
||||
// If protocol is tcp or udp, no target should have method field
|
||||
if (resource.protocol === "tcp" || resource.protocol === "udp") {
|
||||
return resource.targets.every(
|
||||
(target) => target == null || target.method === undefined
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
(resource) => {
|
||||
if (resource.protocol === "http") {
|
||||
return {
|
||||
message:
|
||||
"When protocol is 'http', all targets must have a 'method' field",
|
||||
path: ["targets"]
|
||||
};
|
||||
}
|
||||
return {
|
||||
message:
|
||||
"When protocol is 'tcp' or 'udp', targets must not have a 'method' field",
|
||||
path: ["targets"]
|
||||
};
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(resource) => {
|
||||
if (isTargetsOnlyResource(resource)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If protocol is http, it must have a full-domain
|
||||
if (resource.protocol === "http") {
|
||||
return (
|
||||
resource["full-domain"] !== undefined &&
|
||||
resource["full-domain"].length > 0
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"When protocol is 'http', a 'full-domain' must be provided",
|
||||
path: ["full-domain"]
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(resource) => {
|
||||
if (isTargetsOnlyResource(resource)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If protocol is tcp or udp, it must have both proxy-port
|
||||
if (resource.protocol === "tcp" || resource.protocol === "udp") {
|
||||
return resource["proxy-port"] !== undefined;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"When protocol is 'tcp' or 'udp', 'proxy-port' must be provided",
|
||||
path: ["proxy-port", "exit-node"]
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(resource) => {
|
||||
// Skip validation for targets-only resources
|
||||
if (isTargetsOnlyResource(resource)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If protocol is tcp or udp, it must not have auth
|
||||
if (resource.protocol === "tcp" || resource.protocol === "udp") {
|
||||
return resource.auth === undefined;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"When protocol is 'tcp' or 'udp', 'auth' must not be provided",
|
||||
path: ["auth"]
|
||||
}
|
||||
);
|
||||
|
||||
export function isTargetsOnlyResource(resource: any): boolean {
|
||||
return Object.keys(resource).length === 1 && resource.targets;
|
||||
}
|
||||
|
||||
export const ClientResourceSchema = z.object({
|
||||
name: z.string().min(2).max(100),
|
||||
site: z.string().min(2).max(100).optional(),
|
||||
protocol: z.enum(["tcp", "udp"]),
|
||||
"proxy-port": z.number().min(1).max(65535),
|
||||
"hostname": z.string().min(1).max(255),
|
||||
"internal-port": z.number().min(1).max(65535),
|
||||
enabled: z.boolean().optional().default(true)
|
||||
});
|
||||
|
||||
// Schema for the entire configuration object
|
||||
export const ConfigSchema = z
|
||||
.object({
|
||||
"proxy-resources": z.record(z.string(), ResourceSchema).optional().default({}),
|
||||
"client-resources": z.record(z.string(), ClientResourceSchema).optional().default({}),
|
||||
sites: z.record(z.string(), SiteSchema).optional().default({})
|
||||
})
|
||||
.refine(
|
||||
// Enforce the full-domain uniqueness across resources in the same stack
|
||||
(config) => {
|
||||
// Extract all full-domain values with their resource keys
|
||||
const fullDomainMap = new Map<string, string[]>();
|
||||
|
||||
Object.entries(config["proxy-resources"]).forEach(
|
||||
([resourceKey, resource]) => {
|
||||
const fullDomain = resource["full-domain"];
|
||||
if (fullDomain) {
|
||||
// Only process if full-domain is defined
|
||||
if (!fullDomainMap.has(fullDomain)) {
|
||||
fullDomainMap.set(fullDomain, []);
|
||||
}
|
||||
fullDomainMap.get(fullDomain)!.push(resourceKey);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Find duplicates
|
||||
const duplicates = Array.from(fullDomainMap.entries()).filter(
|
||||
([_, resourceKeys]) => resourceKeys.length > 1
|
||||
);
|
||||
|
||||
return duplicates.length === 0;
|
||||
},
|
||||
(config) => {
|
||||
// Extract duplicates for error message
|
||||
const fullDomainMap = new Map<string, string[]>();
|
||||
|
||||
Object.entries(config["proxy-resources"]).forEach(
|
||||
([resourceKey, resource]) => {
|
||||
const fullDomain = resource["full-domain"];
|
||||
if (fullDomain) {
|
||||
// Only process if full-domain is defined
|
||||
if (!fullDomainMap.has(fullDomain)) {
|
||||
fullDomainMap.set(fullDomain, []);
|
||||
}
|
||||
fullDomainMap.get(fullDomain)!.push(resourceKey);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const duplicates = Array.from(fullDomainMap.entries())
|
||||
.filter(([_, resourceKeys]) => resourceKeys.length > 1)
|
||||
.map(
|
||||
([fullDomain, resourceKeys]) =>
|
||||
`'${fullDomain}' used by resources: ${resourceKeys.join(", ")}`
|
||||
)
|
||||
.join("; ");
|
||||
|
||||
return {
|
||||
message: `Duplicate 'full-domain' values found: ${duplicates}`,
|
||||
path: ["resources"]
|
||||
};
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
// Enforce proxy-port uniqueness within proxy-resources
|
||||
(config) => {
|
||||
const proxyPortMap = new Map<number, string[]>();
|
||||
|
||||
Object.entries(config["proxy-resources"]).forEach(
|
||||
([resourceKey, resource]) => {
|
||||
const proxyPort = resource["proxy-port"];
|
||||
if (proxyPort !== undefined) {
|
||||
if (!proxyPortMap.has(proxyPort)) {
|
||||
proxyPortMap.set(proxyPort, []);
|
||||
}
|
||||
proxyPortMap.get(proxyPort)!.push(resourceKey);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Find duplicates
|
||||
const duplicates = Array.from(proxyPortMap.entries()).filter(
|
||||
([_, resourceKeys]) => resourceKeys.length > 1
|
||||
);
|
||||
|
||||
return duplicates.length === 0;
|
||||
},
|
||||
(config) => {
|
||||
// Extract duplicates for error message
|
||||
const proxyPortMap = new Map<number, string[]>();
|
||||
|
||||
Object.entries(config["proxy-resources"]).forEach(
|
||||
([resourceKey, resource]) => {
|
||||
const proxyPort = resource["proxy-port"];
|
||||
if (proxyPort !== undefined) {
|
||||
if (!proxyPortMap.has(proxyPort)) {
|
||||
proxyPortMap.set(proxyPort, []);
|
||||
}
|
||||
proxyPortMap.get(proxyPort)!.push(resourceKey);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const duplicates = Array.from(proxyPortMap.entries())
|
||||
.filter(([_, resourceKeys]) => resourceKeys.length > 1)
|
||||
.map(
|
||||
([proxyPort, resourceKeys]) =>
|
||||
`port ${proxyPort} used by proxy-resources: ${resourceKeys.join(", ")}`
|
||||
)
|
||||
.join("; ");
|
||||
|
||||
return {
|
||||
message: `Duplicate 'proxy-port' values found in proxy-resources: ${duplicates}`,
|
||||
path: ["proxy-resources"]
|
||||
};
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
// Enforce proxy-port uniqueness within client-resources
|
||||
(config) => {
|
||||
const proxyPortMap = new Map<number, string[]>();
|
||||
|
||||
Object.entries(config["client-resources"]).forEach(
|
||||
([resourceKey, resource]) => {
|
||||
const proxyPort = resource["proxy-port"];
|
||||
if (proxyPort !== undefined) {
|
||||
if (!proxyPortMap.has(proxyPort)) {
|
||||
proxyPortMap.set(proxyPort, []);
|
||||
}
|
||||
proxyPortMap.get(proxyPort)!.push(resourceKey);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Find duplicates
|
||||
const duplicates = Array.from(proxyPortMap.entries()).filter(
|
||||
([_, resourceKeys]) => resourceKeys.length > 1
|
||||
);
|
||||
|
||||
return duplicates.length === 0;
|
||||
},
|
||||
(config) => {
|
||||
// Extract duplicates for error message
|
||||
const proxyPortMap = new Map<number, string[]>();
|
||||
|
||||
Object.entries(config["client-resources"]).forEach(
|
||||
([resourceKey, resource]) => {
|
||||
const proxyPort = resource["proxy-port"];
|
||||
if (proxyPort !== undefined) {
|
||||
if (!proxyPortMap.has(proxyPort)) {
|
||||
proxyPortMap.set(proxyPort, []);
|
||||
}
|
||||
proxyPortMap.get(proxyPort)!.push(resourceKey);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const duplicates = Array.from(proxyPortMap.entries())
|
||||
.filter(([_, resourceKeys]) => resourceKeys.length > 1)
|
||||
.map(
|
||||
([proxyPort, resourceKeys]) =>
|
||||
`port ${proxyPort} used by client-resources: ${resourceKeys.join(", ")}`
|
||||
)
|
||||
.join("; ");
|
||||
|
||||
return {
|
||||
message: `Duplicate 'proxy-port' values found in client-resources: ${duplicates}`,
|
||||
path: ["client-resources"]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Type inference from the schema
|
||||
export type Site = z.infer<typeof SiteSchema>;
|
||||
export type Target = z.infer<typeof TargetSchema>;
|
||||
export type Resource = z.infer<typeof ResourceSchema>;
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
|
@ -2,7 +2,7 @@ import path from "path";
|
|||
import { fileURLToPath } from "url";
|
||||
|
||||
// This is a placeholder value replaced by the build process
|
||||
export const APP_VERSION = "1.9.0";
|
||||
export const APP_VERSION = "1.10.1";
|
||||
|
||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||
export const __DIRNAME = path.dirname(__FILENAME);
|
||||
|
|
|
|||
112
server/lib/domainUtils.ts
Normal file
112
server/lib/domainUtils.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { db } from "@server/db";
|
||||
import { domains, orgDomains } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { subdomainSchema } from "@server/lib/schemas";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
export type DomainValidationResult = {
|
||||
success: true;
|
||||
fullDomain: string;
|
||||
subdomain: string | null;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a domain and constructs the full domain based on domain type and subdomain.
|
||||
*
|
||||
* @param domainId - The ID of the domain to validate
|
||||
* @param orgId - The organization ID to check domain access
|
||||
* @param subdomain - Optional subdomain to append (for ns and wildcard domains)
|
||||
* @returns DomainValidationResult with success status and either fullDomain/subdomain or error message
|
||||
*/
|
||||
export async function validateAndConstructDomain(
|
||||
domainId: string,
|
||||
orgId: string,
|
||||
subdomain?: string | null
|
||||
): Promise<DomainValidationResult> {
|
||||
try {
|
||||
// Query domain with organization access check
|
||||
const [domainRes] = await db
|
||||
.select()
|
||||
.from(domains)
|
||||
.where(eq(domains.domainId, domainId))
|
||||
.leftJoin(
|
||||
orgDomains,
|
||||
and(eq(orgDomains.orgId, orgId), eq(orgDomains.domainId, domainId))
|
||||
);
|
||||
|
||||
// Check if domain exists
|
||||
if (!domainRes || !domainRes.domains) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Domain with ID ${domainId} not found`
|
||||
};
|
||||
}
|
||||
|
||||
// Check if organization has access to domain
|
||||
if (domainRes.orgDomains && domainRes.orgDomains.orgId !== orgId) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Organization does not have access to domain with ID ${domainId}`
|
||||
};
|
||||
}
|
||||
|
||||
// Check if domain is verified
|
||||
if (!domainRes.domains.verified) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Domain with ID ${domainId} is not verified`
|
||||
};
|
||||
}
|
||||
|
||||
// Construct full domain based on domain type
|
||||
let fullDomain = "";
|
||||
let finalSubdomain = subdomain;
|
||||
|
||||
if (domainRes.domains.type === "ns") {
|
||||
if (subdomain) {
|
||||
fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
|
||||
} else {
|
||||
fullDomain = domainRes.domains.baseDomain;
|
||||
}
|
||||
} else if (domainRes.domains.type === "cname") {
|
||||
fullDomain = domainRes.domains.baseDomain;
|
||||
finalSubdomain = null; // CNAME domains don't use subdomains
|
||||
} else if (domainRes.domains.type === "wildcard") {
|
||||
if (subdomain !== undefined && subdomain !== null) {
|
||||
// Validate subdomain format for wildcard domains
|
||||
const parsedSubdomain = subdomainSchema.safeParse(subdomain);
|
||||
if (!parsedSubdomain.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: fromError(parsedSubdomain.error).toString()
|
||||
};
|
||||
}
|
||||
fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
|
||||
} else {
|
||||
fullDomain = domainRes.domains.baseDomain;
|
||||
}
|
||||
}
|
||||
|
||||
// If the full domain equals the base domain, set subdomain to null
|
||||
if (fullDomain === domainRes.domains.baseDomain) {
|
||||
finalSubdomain = null;
|
||||
}
|
||||
|
||||
// Convert to lowercase
|
||||
fullDomain = fullDomain.toLowerCase();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fullDomain,
|
||||
subdomain: finalSubdomain ?? null
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `An error occurred while validating domain: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import logger from "@server/logger";
|
|||
import { ExitNode } from "@server/db";
|
||||
|
||||
interface ExitNodeRequest {
|
||||
remoteType: string;
|
||||
remoteType?: string;
|
||||
localPath: string;
|
||||
method?: "POST" | "DELETE" | "GET" | "PUT";
|
||||
data?: any;
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ export async function listExitNodes(orgId: string, filterOnline = false) {
|
|||
maxConnections: exitNodes.maxConnections,
|
||||
online: exitNodes.online,
|
||||
lastPing: exitNodes.lastPing,
|
||||
type: exitNodes.type
|
||||
type: exitNodes.type,
|
||||
region: exitNodes.region
|
||||
})
|
||||
.from(exitNodes);
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ class TelemetryClient {
|
|||
logger.error("Failed to collect analytics:", err);
|
||||
});
|
||||
},
|
||||
6 * 60 * 60 * 1000
|
||||
48 * 60 * 60 * 1000
|
||||
);
|
||||
|
||||
this.collectAndSendAnalytics().catch((err) => {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
getValidCertificatesForDomains,
|
||||
getValidCertificatesForDomainsHybrid
|
||||
} from "./remoteCertificates";
|
||||
import { sendToExitNode } from "./exitNodeComms";
|
||||
|
||||
export class TraefikConfigManager {
|
||||
private intervalId: NodeJS.Timeout | null = null;
|
||||
|
|
@ -403,27 +404,11 @@ export class TraefikConfigManager {
|
|||
[exitNode] = await db.select().from(exitNodes).limit(1);
|
||||
}
|
||||
if (exitNode) {
|
||||
try {
|
||||
await axios.post(
|
||||
`${exitNode.reachableAt}/update-local-snis`,
|
||||
{ fullDomains: Array.from(domains) },
|
||||
{ headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (error) {
|
||||
// pull data out of the axios error to log
|
||||
if (axios.isAxiosError(error)) {
|
||||
logger.error("Error updating local SNI:", {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
url: error.config?.url,
|
||||
method: error.config?.method
|
||||
});
|
||||
} else {
|
||||
logger.error("Error updating local SNI:", error);
|
||||
}
|
||||
}
|
||||
await sendToExitNode(exitNode, {
|
||||
localPath: "/update-local-snis",
|
||||
method: "POST",
|
||||
data: { fullDomains: Array.from(domains) }
|
||||
});
|
||||
} else {
|
||||
logger.error(
|
||||
"No exit node found. Has gerbil registered yet?"
|
||||
|
|
|
|||
|
|
@ -129,6 +129,40 @@ export function isValidDomain(domain: string): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
export function validateHeaders(headers: string): boolean {
|
||||
// Validate comma-separated headers in format "Header-Name: value"
|
||||
const headerPairs = headers.split(",").map((pair) => pair.trim());
|
||||
return headerPairs.every((pair) => {
|
||||
// Check if the pair contains exactly one colon
|
||||
const colonCount = (pair.match(/:/g) || []).length;
|
||||
if (colonCount !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const colonIndex = pair.indexOf(":");
|
||||
if (colonIndex === 0 || colonIndex === pair.length - 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const headerName = pair.substring(0, colonIndex).trim();
|
||||
const headerValue = pair.substring(colonIndex + 1).trim();
|
||||
|
||||
// Header name should not be empty and should contain valid characters
|
||||
// Header names are case-insensitive and can contain alphanumeric, hyphens
|
||||
const headerNameRegex = /^[a-zA-Z0-9\-_]+$/;
|
||||
if (!headerName || !headerNameRegex.test(headerName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Header value should not be empty and should not contain colons
|
||||
if (!headerValue || headerValue.includes(":")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const validTlds = [
|
||||
"AAA",
|
||||
"AARP",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ export async function verifyApiKeySetResourceUsers(
|
|||
);
|
||||
}
|
||||
|
||||
if (apiKey.isRoot) {
|
||||
// Root keys can access any key in any org
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!req.apiKeyOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
|
|
@ -32,11 +37,6 @@ export async function verifyApiKeySetResourceUsers(
|
|||
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid user IDs"));
|
||||
}
|
||||
|
||||
if (apiKey.isRoot) {
|
||||
// Root keys can access any key in any org
|
||||
return next();
|
||||
}
|
||||
|
||||
if (userIds.length === 0) {
|
||||
return next();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,13 +78,16 @@ authenticated.post(
|
|||
verifyUserHasAction(ActionsEnum.updateOrg),
|
||||
org.updateOrg
|
||||
);
|
||||
authenticated.delete(
|
||||
"/org/:orgId",
|
||||
verifyOrgAccess,
|
||||
verifyUserIsOrgOwner,
|
||||
verifyUserHasAction(ActionsEnum.deleteOrg),
|
||||
org.deleteOrg
|
||||
);
|
||||
|
||||
if (build !== "saas") {
|
||||
authenticated.delete(
|
||||
"/org/:orgId",
|
||||
verifyOrgAccess,
|
||||
verifyUserIsOrgOwner,
|
||||
verifyUserHasAction(ActionsEnum.deleteOrg),
|
||||
org.deleteOrg
|
||||
);
|
||||
}
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/site",
|
||||
|
|
@ -342,6 +345,12 @@ authenticated.get(
|
|||
verifyUserHasAction(ActionsEnum.getResource),
|
||||
resource.getResource
|
||||
);
|
||||
authenticated.get(
|
||||
"/org/:orgId/resource/:niceId",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.getResource),
|
||||
resource.getResource
|
||||
);
|
||||
authenticated.post(
|
||||
"/resource/:resourceId",
|
||||
verifyResourceAccess,
|
||||
|
|
@ -579,6 +588,14 @@ authenticated.put(
|
|||
user.createOrgUser
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/user/:userId",
|
||||
verifyOrgAccess,
|
||||
verifyUserAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateOrgUser),
|
||||
user.updateOrgUser
|
||||
);
|
||||
|
||||
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
|
||||
|
||||
authenticated.post(
|
||||
|
|
@ -953,7 +970,7 @@ authRouter.post(
|
|||
windowMs: 15 * 60 * 1000,
|
||||
max: 15,
|
||||
keyGenerator: (req) =>
|
||||
`requestEmailVerificationCode:${req.body.email || ipKeyGenerator(req.ip || "")}`,
|
||||
`requestEmailVerificationCode:${req.user?.email || ipKeyGenerator(req.ip || "")}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only request an email verification code ${15} times every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { db, idpOidcConfig } from "@server/db";
|
||||
import { domains, idp, orgDomains, users, idpOrg } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
|
@ -33,23 +33,21 @@ async function query(limit: number, offset: number) {
|
|||
idpId: idp.idpId,
|
||||
name: idp.name,
|
||||
type: idp.type,
|
||||
orgCount: sql<number>`count(${idpOrg.orgId})`
|
||||
variant: idpOidcConfig.variant,
|
||||
orgCount: sql<number>`count(${idpOrg.orgId})`,
|
||||
autoProvision: idp.autoProvision
|
||||
})
|
||||
.from(idp)
|
||||
.leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`)
|
||||
.groupBy(idp.idpId)
|
||||
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
|
||||
.groupBy(idp.idpId, idpOidcConfig.variant)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
return res;
|
||||
}
|
||||
|
||||
export type ListIdpsResponse = {
|
||||
idps: Array<{
|
||||
idpId: number;
|
||||
name: string;
|
||||
type: string;
|
||||
orgCount: number;
|
||||
}>;
|
||||
idps: Awaited<ReturnType<typeof query>>;
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
|
|
|
|||
|
|
@ -354,8 +354,13 @@ export async function validateOidcCallback(
|
|||
.from(userOrgs)
|
||||
.where(eq(userOrgs.userId, userId!));
|
||||
|
||||
// Delete orgs that are no longer valid
|
||||
const orgsToDelete = currentUserOrgs.filter(
|
||||
// Filter to only auto-provisioned orgs for CRUD operations
|
||||
const autoProvisionedOrgs = currentUserOrgs.filter(
|
||||
(org) => org.autoProvisioned === true
|
||||
);
|
||||
|
||||
// Delete auto-provisioned orgs that are no longer valid
|
||||
const orgsToDelete = autoProvisionedOrgs.filter(
|
||||
(currentOrg) =>
|
||||
!userOrgInfo.some(
|
||||
(newOrg) => newOrg.orgId === currentOrg.orgId
|
||||
|
|
@ -374,8 +379,8 @@ export async function validateOidcCallback(
|
|||
);
|
||||
}
|
||||
|
||||
// Update roles for existing orgs where the role has changed
|
||||
const orgsToUpdate = currentUserOrgs.filter((currentOrg) => {
|
||||
// Update roles for existing auto-provisioned orgs where the role has changed
|
||||
const orgsToUpdate = autoProvisionedOrgs.filter((currentOrg) => {
|
||||
const newOrg = userOrgInfo.find(
|
||||
(newOrg) => newOrg.orgId === currentOrg.orgId
|
||||
);
|
||||
|
|
@ -401,7 +406,7 @@ export async function validateOidcCallback(
|
|||
}
|
||||
}
|
||||
|
||||
// Add new orgs that don't exist yet
|
||||
// Add new orgs that don't exist yet (these will be auto-provisioned)
|
||||
const orgsToAdd = userOrgInfo.filter(
|
||||
(newOrg) =>
|
||||
!currentUserOrgs.some(
|
||||
|
|
@ -415,12 +420,14 @@ export async function validateOidcCallback(
|
|||
userId: userId!,
|
||||
orgId: org.orgId,
|
||||
roleId: org.roleId,
|
||||
autoProvisioned: true,
|
||||
dateCreated: new Date().toISOString()
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Loop through all the orgs and get the total number of users from the userOrgs table
|
||||
// Use all current user orgs (both auto-provisioned and manually added) for counting
|
||||
for (const org of currentUserOrgs) {
|
||||
const userCount = await trx
|
||||
.select()
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ import {
|
|||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyClientAccess,
|
||||
verifyClientsEnabled,
|
||||
verifyApiKeySiteResourceAccess
|
||||
verifyApiKeySiteResourceAccess,
|
||||
verifyOrgAccess
|
||||
} from "@server/middlewares";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { Router } from "express";
|
||||
|
|
@ -469,6 +470,21 @@ authenticated.get(
|
|||
user.listUsers
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/user",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.createOrgUser),
|
||||
user.createOrgUser
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/user/:userId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyUserAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateOrgUser),
|
||||
user.updateOrgUser
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/user/:userId",
|
||||
verifyApiKeyOrgAccess,
|
||||
|
|
@ -628,3 +644,10 @@ authenticated.post(
|
|||
verifyApiKeyHasAction(ActionsEnum.updateClient),
|
||||
client.updateClient
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/blueprint",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.applyBlueprint),
|
||||
org.applyBlueprint
|
||||
);
|
||||
73
server/routers/newt/handleApplyBlueprintMessage.ts
Normal file
73
server/routers/newt/handleApplyBlueprintMessage.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { db, newts } from "@server/db";
|
||||
import { MessageHandler } from "../ws";
|
||||
import { exitNodes, Newt, resources, sites, Target, targets } from "@server/db";
|
||||
import { eq, and, sql, inArray } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { applyBlueprint } from "@server/lib/blueprints/applyBlueprint";
|
||||
|
||||
export const handleApplyBlueprintMessage: MessageHandler = async (context) => {
|
||||
const { message, client, sendToClient } = context;
|
||||
const newt = client as Newt;
|
||||
|
||||
logger.debug("Handling apply blueprint message!");
|
||||
|
||||
if (!newt) {
|
||||
logger.warn("Newt not found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newt.siteId) {
|
||||
logger.warn("Newt has no site!"); // TODO: Maybe we create the site here?
|
||||
return;
|
||||
}
|
||||
|
||||
// get the site
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, newt.siteId));
|
||||
|
||||
if (!site) {
|
||||
logger.warn("Site not found for newt");
|
||||
return;
|
||||
}
|
||||
|
||||
const { blueprint } = message.data;
|
||||
if (!blueprint) {
|
||||
logger.warn("No blueprint provided");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Received blueprint: ${blueprint}`);
|
||||
|
||||
try {
|
||||
const blueprintParsed = JSON.parse(blueprint);
|
||||
// Update the blueprint in the database
|
||||
await applyBlueprint(site.orgId, blueprintParsed, site.siteId);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update database from config: ${error}`);
|
||||
return {
|
||||
message: {
|
||||
type: "newt/blueprint/results",
|
||||
data: {
|
||||
success: false,
|
||||
message: `Failed to update database from config: ${error}`
|
||||
}
|
||||
},
|
||||
broadcast: false, // Send to all clients
|
||||
excludeSender: false // Include sender in broadcast
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: {
|
||||
type: "newt/blueprint/results",
|
||||
data: {
|
||||
success: true,
|
||||
message: "Config updated successfully"
|
||||
}
|
||||
},
|
||||
broadcast: false, // Send to all clients
|
||||
excludeSender: false // Include sender in broadcast
|
||||
};
|
||||
};
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
getNextAvailableClientSubnet
|
||||
} from "@server/lib/ip";
|
||||
import { selectBestExitNode, verifyExitNodeOrgAccess } from "@server/lib/exitNodes";
|
||||
import { fetchContainers } from "./dockerSocket";
|
||||
|
||||
export type ExitNodePingResult = {
|
||||
exitNodeId: number;
|
||||
|
|
@ -76,6 +77,15 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Docker socket enabled: ${oldSite.dockerSocketEnabled}`);
|
||||
|
||||
if (oldSite.dockerSocketEnabled) {
|
||||
logger.debug(
|
||||
"Site has docker socket enabled - requesting docker containers"
|
||||
);
|
||||
fetchContainers(newt.newtId);
|
||||
}
|
||||
|
||||
let siteSubnet = oldSite.subnet;
|
||||
let exitNodeIdToQuery = oldSite.exitNodeId;
|
||||
if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { MessageHandler } from "../ws";
|
|||
import logger from "@server/logger";
|
||||
import { dockerSocketCache } from "./dockerSocket";
|
||||
import { Newt } from "@server/db";
|
||||
import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint";
|
||||
|
||||
export const handleDockerStatusMessage: MessageHandler = async (context) => {
|
||||
const { message, client, sendToClient } = context;
|
||||
|
|
@ -57,4 +58,15 @@ export const handleDockerContainersMessage: MessageHandler = async (
|
|||
} else {
|
||||
logger.warn(`Newt ${newt.newtId} does not have Docker containers`);
|
||||
}
|
||||
|
||||
if (!newt.siteId) {
|
||||
logger.warn("Newt has no site!");
|
||||
return;
|
||||
}
|
||||
|
||||
await applyNewtDockerBlueprint(
|
||||
newt.siteId,
|
||||
newt.newtId,
|
||||
containers
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,4 +4,5 @@ export * from "./handleNewtRegisterMessage";
|
|||
export * from "./handleReceiveBandwidthMessage";
|
||||
export * from "./handleGetConfigMessage";
|
||||
export * from "./handleSocketMessages";
|
||||
export * from "./handleNewtPingRequestMessage";
|
||||
export * from "./handleNewtPingRequestMessage";
|
||||
export * from "./handleApplyBlueprintMessage";
|
||||
127
server/routers/org/applyBlueprint.ts
Normal file
127
server/routers/org/applyBlueprint.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
apiKeyOrg,
|
||||
apiKeys,
|
||||
domains,
|
||||
Org,
|
||||
orgDomains,
|
||||
orgs,
|
||||
roleActions,
|
||||
roles,
|
||||
userOrgs,
|
||||
users,
|
||||
actions
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { defaultRoleAllowedActions } from "../role";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { isValidCIDR } from "@server/lib/validators";
|
||||
import { applyBlueprint as applyBlueprintFunc } from "@server/lib/blueprints/applyBlueprint";
|
||||
|
||||
const applyBlueprintSchema = z
|
||||
.object({
|
||||
blueprint: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
||||
const applyBlueprintParamsSchema = z
|
||||
.object({
|
||||
orgId: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/org/{orgId}/blueprint",
|
||||
description: "Apply a base64 encoded blueprint to an organization",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: applyBlueprintParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: applyBlueprintSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function applyBlueprint(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = applyBlueprintParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
const parsedBody = applyBlueprintSchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { blueprint } = parsedBody.data;
|
||||
|
||||
if (!blueprint) {
|
||||
logger.warn("No blueprint provided");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Received blueprint: ${blueprint}`);
|
||||
|
||||
try {
|
||||
// first base64 decode the blueprint
|
||||
const decoded = Buffer.from(blueprint, "base64").toString("utf-8");
|
||||
// then parse the json
|
||||
const blueprintParsed = JSON.parse(decoded);
|
||||
|
||||
// Update the blueprint in the database
|
||||
await applyBlueprintFunc(orgId, blueprintParsed);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update database from config: ${error}`);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Failed to update database from config: ${error}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Blueprint applied successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,3 +7,4 @@ export * from "./checkId";
|
|||
export * from "./getOrgOverview";
|
||||
export * from "./listOrgs";
|
||||
export * from "./pickOrgDefaults";
|
||||
export * from "./applyBlueprint";
|
||||
|
|
@ -21,6 +21,8 @@ import { subdomainSchema } from "@server/lib/schemas";
|
|||
import config from "@server/lib/config";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { build } from "@server/build";
|
||||
import { getUniqueResourceName } from "@server/db/names";
|
||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
|
||||
const createResourceParamsSchema = z
|
||||
.object({
|
||||
|
|
@ -193,76 +195,21 @@ async function createHttpResource(
|
|||
}
|
||||
|
||||
const { name, domainId } = parsedBody.data;
|
||||
let subdomain = parsedBody.data.subdomain;
|
||||
const subdomain = parsedBody.data.subdomain;
|
||||
|
||||
const [domainRes] = await db
|
||||
.select()
|
||||
.from(domains)
|
||||
.where(eq(domains.domainId, domainId))
|
||||
.leftJoin(
|
||||
orgDomains,
|
||||
and(eq(orgDomains.orgId, orgId), eq(orgDomains.domainId, domainId))
|
||||
);
|
||||
|
||||
if (!domainRes || !domainRes.domains) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Domain with ID ${domainId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (domainRes.orgDomains && domainRes.orgDomains.orgId !== orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
`Organization does not have access to domain with ID ${domainId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!domainRes.domains.verified) {
|
||||
// Validate domain and construct full domain
|
||||
const domainResult = await validateAndConstructDomain(domainId, orgId, subdomain);
|
||||
|
||||
if (!domainResult.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Domain with ID ${domainRes.domains.domainId} is not verified`
|
||||
domainResult.error
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let fullDomain = "";
|
||||
if (domainRes.domains.type == "ns") {
|
||||
if (subdomain) {
|
||||
fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
|
||||
} else {
|
||||
fullDomain = domainRes.domains.baseDomain;
|
||||
}
|
||||
} else if (domainRes.domains.type == "cname") {
|
||||
fullDomain = domainRes.domains.baseDomain;
|
||||
} else if (domainRes.domains.type == "wildcard") {
|
||||
if (subdomain) {
|
||||
// the subdomain cant have a dot in it
|
||||
const parsedSubdomain = subdomainSchema.safeParse(subdomain);
|
||||
if (!parsedSubdomain.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedSubdomain.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
|
||||
} else {
|
||||
fullDomain = domainRes.domains.baseDomain;
|
||||
}
|
||||
}
|
||||
|
||||
if (fullDomain === domainRes.domains.baseDomain) {
|
||||
subdomain = null;
|
||||
}
|
||||
|
||||
fullDomain = fullDomain.toLowerCase();
|
||||
const { fullDomain, subdomain: finalSubdomain } = domainResult;
|
||||
|
||||
logger.debug(`Full domain: ${fullDomain}`);
|
||||
|
||||
|
|
@ -283,15 +230,18 @@ async function createHttpResource(
|
|||
|
||||
let resource: Resource | undefined;
|
||||
|
||||
const niceId = await getUniqueResourceName(orgId);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const newResource = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
niceId,
|
||||
fullDomain,
|
||||
domainId,
|
||||
orgId,
|
||||
name,
|
||||
subdomain,
|
||||
subdomain: finalSubdomain,
|
||||
http: true,
|
||||
protocol: "tcp",
|
||||
ssl: true
|
||||
|
|
@ -391,10 +341,13 @@ async function createRawResource(
|
|||
|
||||
let resource: Resource | undefined;
|
||||
|
||||
const niceId = await getUniqueResourceName(orgId);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const newResource = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
niceId,
|
||||
orgId,
|
||||
name,
|
||||
http,
|
||||
|
|
|
|||
|
|
@ -2,32 +2,72 @@ import { Request, Response, NextFunction } from "express";
|
|||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { Resource, resources, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import stoi from "@server/lib/stoi";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const getResourceSchema = z
|
||||
.object({
|
||||
resourceId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
.optional()
|
||||
.transform(stoi)
|
||||
.pipe(z.number().int().positive().optional())
|
||||
.optional(),
|
||||
niceId: z.string().optional(),
|
||||
orgId: z.string().optional()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type GetResourceResponse = Resource;
|
||||
async function query(resourceId?: number, niceId?: string, orgId?: string) {
|
||||
if (resourceId) {
|
||||
const [res] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
return res;
|
||||
} else if (niceId && orgId) {
|
||||
const [res] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(and(eq(resources.niceId, niceId), eq(resources.orgId, orgId)))
|
||||
.limit(1);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export type GetResourceResponse = NonNullable<Awaited<ReturnType<typeof query>>>;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/resource/{niceId}",
|
||||
description:
|
||||
"Get a resource by orgId and niceId. NiceId is a readable ID for the resource and unique on a per org basis.",
|
||||
tags: [OpenAPITags.Org, OpenAPITags.Resource],
|
||||
request: {
|
||||
params: z.object({
|
||||
orgId: z.string(),
|
||||
niceId: z.string()
|
||||
})
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/resource/{resourceId}",
|
||||
description: "Get a resource.",
|
||||
description: "Get a resource by resourceId.",
|
||||
tags: [OpenAPITags.Resource],
|
||||
request: {
|
||||
params: getResourceSchema
|
||||
params: z.object({
|
||||
resourceId: z.number()
|
||||
})
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
|
@ -48,29 +88,18 @@ export async function getResource(
|
|||
);
|
||||
}
|
||||
|
||||
const { resourceId } = parsedParams.data;
|
||||
const { resourceId, niceId, orgId } = parsedParams.data;
|
||||
|
||||
const [resp] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
const resource = resp;
|
||||
const resource = await query(resourceId, niceId, orgId);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found`
|
||||
)
|
||||
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: {
|
||||
...resource
|
||||
},
|
||||
return response<GetResourceResponse>(res, {
|
||||
data: resource,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource retrieved successfully",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export type GetResourceAuthInfoResponse = {
|
|||
url: string;
|
||||
whitelist: boolean;
|
||||
skipToIdpId: number | null;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export async function getResourceAuthInfo(
|
||||
|
|
@ -88,7 +89,8 @@ export async function getResourceAuthInfo(
|
|||
blockAccess: resource.blockAccess,
|
||||
url,
|
||||
whitelist: resource.emailWhitelistEnabled,
|
||||
skipToIdpId: resource.skipToIdpId
|
||||
skipToIdpId: resource.skipToIdpId,
|
||||
orgId: resource.orgId
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import logger from "@server/logger";
|
|||
import stoi from "@server/lib/stoi";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { warn } from "console";
|
||||
|
||||
const listResourcesParamsSchema = z
|
||||
.object({
|
||||
|
|
@ -54,7 +55,8 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
|
|||
protocol: resources.protocol,
|
||||
proxyPort: resources.proxyPort,
|
||||
enabled: resources.enabled,
|
||||
domainId: resources.domainId
|
||||
domainId: resources.domainId,
|
||||
niceId: resources.niceId
|
||||
})
|
||||
.from(resources)
|
||||
.leftJoin(
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import { tlsNameSchema } from "@server/lib/schemas";
|
|||
import { subdomainSchema } from "@server/lib/schemas";
|
||||
import { registry } from "@server/openApi";
|
||||
import { OpenAPITags } from "@server/openApi";
|
||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
import { validateHeaders } from "@server/lib/validators";
|
||||
|
||||
const updateResourceParamsSchema = z
|
||||
.object({
|
||||
|
|
@ -44,7 +46,8 @@ const updateHttpResourceBodySchema = z
|
|||
stickySession: z.boolean().optional(),
|
||||
tlsServerName: z.string().nullable().optional(),
|
||||
setHostHeader: z.string().nullable().optional(),
|
||||
skipToIdpId: z.number().int().positive().nullable().optional()
|
||||
skipToIdpId: z.number().int().positive().nullable().optional(),
|
||||
headers: z.string().nullable().optional()
|
||||
})
|
||||
.strict()
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
|
|
@ -82,6 +85,18 @@ const updateHttpResourceBodySchema = z
|
|||
message:
|
||||
"Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header."
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.headers) {
|
||||
return validateHeaders(data.headers);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Invalid headers format. Use comma-separated format: 'Header-Name: value, Another-Header: another-value'. Header values cannot contain colons."
|
||||
}
|
||||
);
|
||||
|
||||
export type UpdateResourceResponse = Resource;
|
||||
|
|
@ -230,78 +245,19 @@ async function updateHttpResource(
|
|||
if (updateData.domainId) {
|
||||
const domainId = updateData.domainId;
|
||||
|
||||
const [domainRes] = await db
|
||||
.select()
|
||||
.from(domains)
|
||||
.where(eq(domains.domainId, domainId))
|
||||
.leftJoin(
|
||||
orgDomains,
|
||||
and(
|
||||
eq(orgDomains.orgId, resource.orgId),
|
||||
eq(orgDomains.domainId, domainId)
|
||||
)
|
||||
);
|
||||
|
||||
if (!domainRes || !domainRes.domains) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Domain with ID ${updateData.domainId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
domainRes.orgDomains &&
|
||||
domainRes.orgDomains.orgId !== resource.orgId
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
`You do not have permission to use domain with ID ${updateData.domainId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!domainRes.domains.verified) {
|
||||
// Validate domain and construct full domain
|
||||
const domainResult = await validateAndConstructDomain(domainId, resource.orgId, updateData.subdomain);
|
||||
|
||||
if (!domainResult.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Domain with ID ${updateData.domainId} is not verified`
|
||||
domainResult.error
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let fullDomain = "";
|
||||
if (domainRes.domains.type == "ns") {
|
||||
if (updateData.subdomain) {
|
||||
fullDomain = `${updateData.subdomain}.${domainRes.domains.baseDomain}`;
|
||||
} else {
|
||||
fullDomain = domainRes.domains.baseDomain;
|
||||
}
|
||||
} else if (domainRes.domains.type == "cname") {
|
||||
fullDomain = domainRes.domains.baseDomain;
|
||||
} else if (domainRes.domains.type == "wildcard") {
|
||||
if (updateData.subdomain !== undefined) {
|
||||
// the subdomain cant have a dot in it
|
||||
const parsedSubdomain = subdomainSchema.safeParse(
|
||||
updateData.subdomain
|
||||
);
|
||||
if (!parsedSubdomain.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedSubdomain.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
fullDomain = `${updateData.subdomain}.${domainRes.domains.baseDomain}`;
|
||||
} else {
|
||||
fullDomain = domainRes.domains.baseDomain;
|
||||
}
|
||||
}
|
||||
|
||||
fullDomain = fullDomain.toLowerCase();
|
||||
const { fullDomain, subdomain: finalSubdomain } = domainResult;
|
||||
|
||||
logger.debug(`Full domain: ${fullDomain}`);
|
||||
|
||||
|
|
@ -332,9 +288,8 @@ async function updateHttpResource(
|
|||
.where(eq(resources.resourceId, resource.resourceId));
|
||||
}
|
||||
|
||||
if (fullDomain === domainRes.domains.baseDomain) {
|
||||
updateData.subdomain = null;
|
||||
}
|
||||
// Update the subdomain in the update data
|
||||
updateData.subdomain = finalSubdomain;
|
||||
}
|
||||
|
||||
const updatedResource = await db
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ export async function pickSiteDefaults(
|
|||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Organization retrieved successfully",
|
||||
message: "Site defaults chosen successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { fromError } from "zod-validation-error";
|
|||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { addTargets } from "../client/targets";
|
||||
import { getUniqueSiteResourceName } from "@server/db/names";
|
||||
|
||||
const createSiteResourceParamsSchema = z
|
||||
.object({
|
||||
|
|
@ -121,11 +122,14 @@ export async function createSiteResource(
|
|||
);
|
||||
}
|
||||
|
||||
const niceId = await getUniqueSiteResourceName(orgId);
|
||||
|
||||
// Create the site resource
|
||||
const [newSiteResource] = await db
|
||||
.insert(siteResources)
|
||||
.values({
|
||||
siteId,
|
||||
niceId,
|
||||
orgId,
|
||||
name,
|
||||
protocol,
|
||||
|
|
|
|||
|
|
@ -12,21 +12,72 @@ import { OpenAPITags, registry } from "@server/openApi";
|
|||
|
||||
const getSiteResourceParamsSchema = z
|
||||
.object({
|
||||
siteResourceId: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||
siteResourceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val ? Number(val) : undefined)
|
||||
.pipe(z.number().int().positive().optional())
|
||||
.optional(),
|
||||
siteId: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||
niceId: z.string().optional(),
|
||||
orgId: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type GetSiteResourceResponse = SiteResource;
|
||||
async function query(siteResourceId?: number, siteId?: number, niceId?: string, orgId?: string) {
|
||||
if (siteResourceId && siteId && orgId) {
|
||||
const [siteResource] = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(and(
|
||||
eq(siteResources.siteResourceId, siteResourceId),
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
))
|
||||
.limit(1);
|
||||
return siteResource;
|
||||
} else if (niceId && siteId && orgId) {
|
||||
const [siteResource] = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(and(
|
||||
eq(siteResources.niceId, niceId),
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
))
|
||||
.limit(1);
|
||||
return siteResource;
|
||||
}
|
||||
}
|
||||
|
||||
export type GetSiteResourceResponse = NonNullable<Awaited<ReturnType<typeof query>>>;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}",
|
||||
description: "Get a specific site resource.",
|
||||
description: "Get a specific site resource by siteResourceId.",
|
||||
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
||||
request: {
|
||||
params: getSiteResourceParamsSchema
|
||||
params: z.object({
|
||||
siteResourceId: z.number(),
|
||||
siteId: z.number(),
|
||||
orgId: z.string()
|
||||
})
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/site/{siteId}/resource/nice/{niceId}",
|
||||
description: "Get a specific site resource by niceId.",
|
||||
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
||||
request: {
|
||||
params: z.object({
|
||||
niceId: z.string(),
|
||||
siteId: z.number(),
|
||||
orgId: z.string()
|
||||
})
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
|
@ -47,18 +98,10 @@ export async function getSiteResource(
|
|||
);
|
||||
}
|
||||
|
||||
const { siteResourceId, siteId, orgId } = parsedParams.data;
|
||||
const { siteResourceId, siteId, niceId, orgId } = parsedParams.data;
|
||||
|
||||
// Get the site resource
|
||||
const [siteResource] = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(and(
|
||||
eq(siteResources.siteResourceId, siteResourceId),
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
))
|
||||
.limit(1);
|
||||
const siteResource = await query(siteResourceId, siteId, niceId, orgId);
|
||||
|
||||
if (!siteResource) {
|
||||
return next(
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const updateSiteResourceSchema = z
|
|||
protocol: z.enum(["tcp", "udp"]).optional(),
|
||||
proxyPort: z.number().int().positive().optional(),
|
||||
destinationPort: z.number().int().positive().optional(),
|
||||
destinationIp: z.string().ip().optional(),
|
||||
destinationIp: z.string().optional(),
|
||||
enabled: z.boolean().optional()
|
||||
})
|
||||
.strict();
|
||||
|
|
|
|||
|
|
@ -30,7 +30,9 @@ const createTargetSchema = z
|
|||
ip: z.string().refine(isTargetValid),
|
||||
method: z.string().optional().nullable(),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
enabled: z.boolean().default(true)
|
||||
enabled: z.boolean().default(true),
|
||||
path: z.string().optional().nullable(),
|
||||
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable()
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
@ -161,7 +163,7 @@ export async function createTarget(
|
|||
);
|
||||
}
|
||||
|
||||
const { internalPort, targetIps } = await pickPort(site.siteId!);
|
||||
const { internalPort, targetIps } = await pickPort(site.siteId!, db);
|
||||
|
||||
if (!internalPort) {
|
||||
return next(
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { db } from "@server/db";
|
||||
import { db, Transaction } from "@server/db";
|
||||
import { resources, targets } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const currentBannedPorts: number[] = [];
|
||||
|
||||
export async function pickPort(siteId: number): Promise<{
|
||||
export async function pickPort(siteId: number, trx: Transaction | typeof db): Promise<{
|
||||
internalPort: number;
|
||||
targetIps: string[];
|
||||
}> {
|
||||
|
|
@ -12,7 +12,7 @@ export async function pickPort(siteId: number): Promise<{
|
|||
const targetIps: string[] = [];
|
||||
const targetInternalPorts: number[] = [];
|
||||
|
||||
const targetsRes = await db
|
||||
const targetsRes = await trx
|
||||
.select()
|
||||
.from(targets)
|
||||
.where(eq(targets.siteId, siteId));
|
||||
|
|
|
|||
|
|
@ -44,7 +44,9 @@ function queryTargets(resourceId: number) {
|
|||
enabled: targets.enabled,
|
||||
resourceId: targets.resourceId,
|
||||
siteId: targets.siteId,
|
||||
siteType: sites.type
|
||||
siteType: sites.type,
|
||||
path: targets.path,
|
||||
pathMatchType: targets.pathMatchType
|
||||
})
|
||||
.from(targets)
|
||||
.leftJoin(sites, eq(sites.siteId, targets.siteId))
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ const updateTargetBodySchema = z
|
|||
ip: z.string().refine(isTargetValid),
|
||||
method: z.string().min(1).max(10).optional().nullable(),
|
||||
port: z.number().int().min(1).max(65535).optional(),
|
||||
enabled: z.boolean().optional()
|
||||
enabled: z.boolean().optional(),
|
||||
path: z.string().optional().nullable(),
|
||||
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable()
|
||||
})
|
||||
.strict()
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
|
|
@ -153,7 +155,7 @@ export async function updateTarget(
|
|||
);
|
||||
}
|
||||
|
||||
const { internalPort, targetIps } = await pickPort(site.siteId!);
|
||||
const { internalPort, targetIps } = await pickPort(site.siteId!, db);
|
||||
|
||||
if (!internalPort) {
|
||||
return next(
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ export async function traefikConfigProvider(
|
|||
config.getRawConfig().traefik.site_types
|
||||
);
|
||||
|
||||
if (traefikConfig?.http?.middlewares) { // BECAUSE SOMETIMES THE CONFIG CAN BE EMPTY IF THERE IS NOTHING
|
||||
if (traefikConfig?.http?.middlewares) {
|
||||
// BECAUSE SOMETIMES THE CONFIG CAN BE EMPTY IF THERE IS NOTHING
|
||||
traefikConfig.http.middlewares[badgerMiddlewareName] = {
|
||||
plugin: {
|
||||
[badgerMiddlewareName]: {
|
||||
|
|
@ -104,106 +105,112 @@ export async function getTraefikConfig(
|
|||
};
|
||||
};
|
||||
|
||||
// Get all resources with related data
|
||||
const allResources = await db.transaction(async (tx) => {
|
||||
// Get resources with their targets and sites in a single optimized query
|
||||
// Start from sites on this exit node, then join to targets and resources
|
||||
const resourcesWithTargetsAndSites = await tx
|
||||
.select({
|
||||
// Resource fields
|
||||
resourceId: resources.resourceId,
|
||||
fullDomain: resources.fullDomain,
|
||||
ssl: resources.ssl,
|
||||
http: resources.http,
|
||||
proxyPort: resources.proxyPort,
|
||||
protocol: resources.protocol,
|
||||
subdomain: resources.subdomain,
|
||||
domainId: resources.domainId,
|
||||
enabled: resources.enabled,
|
||||
stickySession: resources.stickySession,
|
||||
tlsServerName: resources.tlsServerName,
|
||||
setHostHeader: resources.setHostHeader,
|
||||
enableProxy: resources.enableProxy,
|
||||
// Target fields
|
||||
targetId: targets.targetId,
|
||||
targetEnabled: targets.enabled,
|
||||
ip: targets.ip,
|
||||
method: targets.method,
|
||||
port: targets.port,
|
||||
internalPort: targets.internalPort,
|
||||
// Site fields
|
||||
siteId: sites.siteId,
|
||||
siteType: sites.type,
|
||||
siteOnline: sites.online,
|
||||
subnet: sites.subnet,
|
||||
exitNodeId: sites.exitNodeId
|
||||
})
|
||||
.from(sites)
|
||||
.innerJoin(targets, eq(targets.siteId, sites.siteId))
|
||||
.innerJoin(resources, eq(resources.resourceId, targets.resourceId))
|
||||
.where(
|
||||
and(
|
||||
eq(targets.enabled, true),
|
||||
eq(resources.enabled, true),
|
||||
or(
|
||||
eq(sites.exitNodeId, exitNodeId),
|
||||
isNull(sites.exitNodeId)
|
||||
),
|
||||
inArray(sites.type, siteTypes),
|
||||
config.getRawConfig().traefik.allow_raw_resources
|
||||
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
|
||||
: eq(resources.http, true),
|
||||
)
|
||||
);
|
||||
// Get resources with their targets and sites in a single optimized query
|
||||
// Start from sites on this exit node, then join to targets and resources
|
||||
const resourcesWithTargetsAndSites = await db
|
||||
.select({
|
||||
// Resource fields
|
||||
resourceId: resources.resourceId,
|
||||
fullDomain: resources.fullDomain,
|
||||
ssl: resources.ssl,
|
||||
http: resources.http,
|
||||
proxyPort: resources.proxyPort,
|
||||
protocol: resources.protocol,
|
||||
subdomain: resources.subdomain,
|
||||
domainId: resources.domainId,
|
||||
enabled: resources.enabled,
|
||||
stickySession: resources.stickySession,
|
||||
tlsServerName: resources.tlsServerName,
|
||||
setHostHeader: resources.setHostHeader,
|
||||
enableProxy: resources.enableProxy,
|
||||
headers: resources.headers,
|
||||
// Target fields
|
||||
targetId: targets.targetId,
|
||||
targetEnabled: targets.enabled,
|
||||
ip: targets.ip,
|
||||
method: targets.method,
|
||||
port: targets.port,
|
||||
internalPort: targets.internalPort,
|
||||
path: targets.path,
|
||||
pathMatchType: targets.pathMatchType,
|
||||
|
||||
// Group by resource and include targets with their unique site data
|
||||
const resourcesMap = new Map();
|
||||
// Site fields
|
||||
siteId: sites.siteId,
|
||||
siteType: sites.type,
|
||||
siteOnline: sites.online,
|
||||
subnet: sites.subnet,
|
||||
exitNodeId: sites.exitNodeId
|
||||
})
|
||||
.from(sites)
|
||||
.innerJoin(targets, eq(targets.siteId, sites.siteId))
|
||||
.innerJoin(resources, eq(resources.resourceId, targets.resourceId))
|
||||
.where(
|
||||
and(
|
||||
eq(targets.enabled, true),
|
||||
eq(resources.enabled, true),
|
||||
or(eq(sites.exitNodeId, exitNodeId), isNull(sites.exitNodeId)),
|
||||
inArray(sites.type, siteTypes),
|
||||
config.getRawConfig().traefik.allow_raw_resources
|
||||
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
|
||||
: eq(resources.http, true)
|
||||
)
|
||||
);
|
||||
|
||||
resourcesWithTargetsAndSites.forEach((row) => {
|
||||
const resourceId = row.resourceId;
|
||||
// Group by resource and include targets with their unique site data
|
||||
const resourcesMap = new Map();
|
||||
|
||||
if (!resourcesMap.has(resourceId)) {
|
||||
resourcesMap.set(resourceId, {
|
||||
resourceId: row.resourceId,
|
||||
fullDomain: row.fullDomain,
|
||||
ssl: row.ssl,
|
||||
http: row.http,
|
||||
proxyPort: row.proxyPort,
|
||||
protocol: row.protocol,
|
||||
subdomain: row.subdomain,
|
||||
domainId: row.domainId,
|
||||
enabled: row.enabled,
|
||||
stickySession: row.stickySession,
|
||||
tlsServerName: row.tlsServerName,
|
||||
setHostHeader: row.setHostHeader,
|
||||
enableProxy: row.enableProxy,
|
||||
targets: []
|
||||
});
|
||||
}
|
||||
resourcesWithTargetsAndSites.forEach((row) => {
|
||||
const resourceId = row.resourceId;
|
||||
const targetPath = sanitizePath(row.path) || ""; // Handle null/undefined paths
|
||||
const pathMatchType = row.pathMatchType || "";
|
||||
|
||||
// Add target with its associated site data
|
||||
resourcesMap.get(resourceId).targets.push({
|
||||
// Create a unique key combining resourceId and path+pathMatchType
|
||||
const pathKey = [targetPath, pathMatchType].filter(Boolean).join("-");
|
||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||
|
||||
if (!resourcesMap.has(mapKey)) {
|
||||
resourcesMap.set(mapKey, {
|
||||
resourceId: row.resourceId,
|
||||
targetId: row.targetId,
|
||||
ip: row.ip,
|
||||
method: row.method,
|
||||
port: row.port,
|
||||
internalPort: row.internalPort,
|
||||
enabled: row.targetEnabled,
|
||||
site: {
|
||||
siteId: row.siteId,
|
||||
type: row.siteType,
|
||||
subnet: row.subnet,
|
||||
exitNodeId: row.exitNodeId,
|
||||
online: row.siteOnline
|
||||
}
|
||||
fullDomain: row.fullDomain,
|
||||
ssl: row.ssl,
|
||||
http: row.http,
|
||||
proxyPort: row.proxyPort,
|
||||
protocol: row.protocol,
|
||||
subdomain: row.subdomain,
|
||||
domainId: row.domainId,
|
||||
enabled: row.enabled,
|
||||
stickySession: row.stickySession,
|
||||
tlsServerName: row.tlsServerName,
|
||||
setHostHeader: row.setHostHeader,
|
||||
enableProxy: row.enableProxy,
|
||||
targets: [],
|
||||
headers: row.headers,
|
||||
path: row.path, // the targets will all have the same path
|
||||
pathMatchType: row.pathMatchType // the targets will all have the same pathMatchType
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(resourcesMap.values());
|
||||
// Add target with its associated site data
|
||||
resourcesMap.get(mapKey).targets.push({
|
||||
resourceId: row.resourceId,
|
||||
targetId: row.targetId,
|
||||
ip: row.ip,
|
||||
method: row.method,
|
||||
port: row.port,
|
||||
internalPort: row.internalPort,
|
||||
enabled: row.targetEnabled,
|
||||
site: {
|
||||
siteId: row.siteId,
|
||||
type: row.siteType,
|
||||
subnet: row.subnet,
|
||||
exitNodeId: row.exitNodeId,
|
||||
online: row.siteOnline
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!allResources.length) {
|
||||
// make sure we have at least one resource
|
||||
if (resourcesMap.size === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
|
@ -219,14 +226,15 @@ export async function getTraefikConfig(
|
|||
}
|
||||
};
|
||||
|
||||
for (const resource of allResources) {
|
||||
// get the key and the resource
|
||||
for (const [key, resource] of resourcesMap.entries()) {
|
||||
const targets = resource.targets;
|
||||
|
||||
const routerName = `${resource.resourceId}-router`;
|
||||
const serviceName = `${resource.resourceId}-service`;
|
||||
const routerName = `${key}-router`;
|
||||
const serviceName = `${key}-service`;
|
||||
const fullDomain = `${resource.fullDomain}`;
|
||||
const transportName = `${resource.resourceId}-transport`;
|
||||
const hostHeaderMiddlewareName = `${resource.resourceId}-host-header-middleware`;
|
||||
const transportName = `${key}-transport`;
|
||||
const headersMiddlewareName = `${key}-headers-middleware`;
|
||||
|
||||
if (!resource.enabled) {
|
||||
continue;
|
||||
|
|
@ -238,9 +246,6 @@ export async function getTraefikConfig(
|
|||
}
|
||||
|
||||
if (!resource.fullDomain) {
|
||||
logger.error(
|
||||
`Resource ${resource.resourceId} has no fullDomain`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -296,16 +301,68 @@ export async function getTraefikConfig(
|
|||
const additionalMiddlewares =
|
||||
config.getRawConfig().traefik.additional_middlewares || [];
|
||||
|
||||
const routerMiddlewares = [
|
||||
badgerMiddlewareName,
|
||||
...additionalMiddlewares
|
||||
];
|
||||
|
||||
if (resource.headers && resource.headers.length > 0) {
|
||||
// if there are headers, parse them into an object
|
||||
const headersObj: { [key: string]: string } = {};
|
||||
const headersArr = resource.headers.split(",");
|
||||
for (const header of headersArr) {
|
||||
const [key, value] = header
|
||||
.split(":")
|
||||
.map((s: string) => s.trim());
|
||||
if (key && value) {
|
||||
headersObj[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (resource.setHostHeader) {
|
||||
headersObj["Host"] = resource.setHostHeader;
|
||||
}
|
||||
|
||||
// check if the object is not empty
|
||||
if (Object.keys(headersObj).length > 0) {
|
||||
// Add the headers middleware
|
||||
if (!config_output.http.middlewares) {
|
||||
config_output.http.middlewares = {};
|
||||
}
|
||||
config_output.http.middlewares[headersMiddlewareName] = {
|
||||
headers: {
|
||||
customRequestHeaders: headersObj
|
||||
}
|
||||
};
|
||||
|
||||
routerMiddlewares.push(headersMiddlewareName);
|
||||
}
|
||||
}
|
||||
|
||||
let rule = `Host(\`${fullDomain}\`)`;
|
||||
let priority = 100;
|
||||
if (resource.path && resource.pathMatchType) {
|
||||
priority += 1;
|
||||
// add path to rule based on match type
|
||||
if (resource.pathMatchType === "exact") {
|
||||
rule += ` && Path(\`${resource.path}\`)`;
|
||||
} else if (resource.pathMatchType === "prefix") {
|
||||
rule += ` && PathPrefix(\`${resource.path}\`)`;
|
||||
} else if (resource.pathMatchType === "regex") {
|
||||
rule += ` && PathRegexp(\`${resource.path}\`)`;
|
||||
}
|
||||
}
|
||||
|
||||
config_output.http.routers![routerName] = {
|
||||
entryPoints: [
|
||||
resource.ssl
|
||||
? config.getRawConfig().traefik.https_entrypoint
|
||||
: config.getRawConfig().traefik.http_entrypoint
|
||||
],
|
||||
middlewares: [badgerMiddlewareName, ...additionalMiddlewares],
|
||||
middlewares: routerMiddlewares,
|
||||
service: serviceName,
|
||||
rule: `Host(\`${fullDomain}\`)`,
|
||||
priority: 100,
|
||||
rule: rule,
|
||||
priority: priority,
|
||||
...(resource.ssl ? { tls } : {})
|
||||
};
|
||||
|
||||
|
|
@ -316,8 +373,8 @@ export async function getTraefikConfig(
|
|||
],
|
||||
middlewares: [redirectHttpsMiddlewareName],
|
||||
service: serviceName,
|
||||
rule: `Host(\`${fullDomain}\`)`,
|
||||
priority: 100
|
||||
rule: rule,
|
||||
priority: priority
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -334,55 +391,64 @@ export async function getTraefikConfig(
|
|||
targets as TargetWithSite[]
|
||||
).some((target: TargetWithSite) => target.site.online);
|
||||
|
||||
return (targets as TargetWithSite[])
|
||||
.filter((target: TargetWithSite) => {
|
||||
if (!target.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If any sites are online, exclude offline sites
|
||||
if (anySitesOnline && !target.site.online) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
) {
|
||||
if (
|
||||
!target.ip ||
|
||||
!target.port ||
|
||||
!target.method
|
||||
) {
|
||||
return (
|
||||
(targets as TargetWithSite[])
|
||||
.filter((target: TargetWithSite) => {
|
||||
if (!target.enabled) {
|
||||
return false;
|
||||
}
|
||||
} else if (target.site.type === "newt") {
|
||||
if (
|
||||
!target.internalPort ||
|
||||
!target.method ||
|
||||
!target.site.subnet
|
||||
) {
|
||||
|
||||
// If any sites are online, exclude offline sites
|
||||
if (anySitesOnline && !target.site.online) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((target: TargetWithSite) => {
|
||||
if (
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
) {
|
||||
return {
|
||||
url: `${target.method}://${target.ip}:${target.port}`
|
||||
};
|
||||
} else if (target.site.type === "newt") {
|
||||
const ip =
|
||||
target.site.subnet!.split("/")[0];
|
||||
return {
|
||||
url: `${target.method}://${ip}:${target.internalPort}`
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
) {
|
||||
if (
|
||||
!target.ip ||
|
||||
!target.port ||
|
||||
!target.method
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
} else if (target.site.type === "newt") {
|
||||
if (
|
||||
!target.internalPort ||
|
||||
!target.method ||
|
||||
!target.site.subnet
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((target: TargetWithSite) => {
|
||||
if (
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
) {
|
||||
return {
|
||||
url: `${target.method}://${target.ip}:${target.port}`
|
||||
};
|
||||
} else if (target.site.type === "newt") {
|
||||
const ip =
|
||||
target.site.subnet!.split("/")[0];
|
||||
return {
|
||||
url: `${target.method}://${ip}:${target.internalPort}`
|
||||
};
|
||||
}
|
||||
})
|
||||
// filter out duplicates
|
||||
.filter(
|
||||
(v, i, a) =>
|
||||
a.findIndex(
|
||||
(t) => t && v && t.url === v.url
|
||||
) === i
|
||||
)
|
||||
);
|
||||
})(),
|
||||
...(resource.stickySession
|
||||
? {
|
||||
|
|
@ -413,27 +479,6 @@ export async function getTraefikConfig(
|
|||
serviceName
|
||||
].loadBalancer.serversTransport = transportName;
|
||||
}
|
||||
|
||||
// Add the host header middleware
|
||||
if (resource.setHostHeader) {
|
||||
if (!config_output.http.middlewares) {
|
||||
config_output.http.middlewares = {};
|
||||
}
|
||||
config_output.http.middlewares[hostHeaderMiddlewareName] = {
|
||||
headers: {
|
||||
customRequestHeaders: {
|
||||
Host: resource.setHostHeader
|
||||
}
|
||||
}
|
||||
};
|
||||
if (!config_output.http.routers![routerName].middlewares) {
|
||||
config_output.http.routers![routerName].middlewares = [];
|
||||
}
|
||||
config_output.http.routers![routerName].middlewares = [
|
||||
...config_output.http.routers![routerName].middlewares,
|
||||
hostHeaderMiddlewareName
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// Non-HTTP (TCP/UDP) configuration
|
||||
if (!resource.enableProxy) {
|
||||
|
|
@ -529,3 +574,13 @@ export async function getTraefikConfig(
|
|||
}
|
||||
return config_output;
|
||||
}
|
||||
|
||||
function sanitizePath(path: string | null | undefined): string | undefined {
|
||||
if (!path) return undefined;
|
||||
// clean any non alphanumeric characters from the path and replace with dashes
|
||||
// the path cant be too long either, so limit to 50 characters
|
||||
if (path.length > 50) {
|
||||
path = path.substring(0, 50);
|
||||
}
|
||||
return path.replace(/[^a-zA-Z0-9]/g, "");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,14 @@ export async function createOrgUser(
|
|||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const { username, email, name, type, idpId, roleId } = parsedBody.data;
|
||||
const {
|
||||
username,
|
||||
email,
|
||||
name,
|
||||
type,
|
||||
idpId,
|
||||
roleId
|
||||
} = parsedBody.data;
|
||||
|
||||
const [role] = await db
|
||||
.select()
|
||||
|
|
@ -141,7 +148,12 @@ export async function createOrgUser(
|
|||
const [existingUser] = await trx
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username));
|
||||
.where(
|
||||
and(
|
||||
eq(users.username, username),
|
||||
eq(users.idpId, idpId)
|
||||
)
|
||||
);
|
||||
|
||||
if (existingUser) {
|
||||
const [existingOrgUser] = await trx
|
||||
|
|
@ -168,7 +180,8 @@ export async function createOrgUser(
|
|||
.values({
|
||||
orgId,
|
||||
userId: existingUser.userId,
|
||||
roleId: role.roleId
|
||||
roleId: role.roleId,
|
||||
autoProvisioned: false
|
||||
})
|
||||
.returning();
|
||||
} else {
|
||||
|
|
@ -184,7 +197,7 @@ export async function createOrgUser(
|
|||
type: "oidc",
|
||||
idpId,
|
||||
dateCreated: new Date().toISOString(),
|
||||
emailVerified: true
|
||||
emailVerified: true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
|
@ -193,7 +206,8 @@ export async function createOrgUser(
|
|||
.values({
|
||||
orgId,
|
||||
userId: newUser.userId,
|
||||
roleId: role.roleId
|
||||
roleId: role.roleId,
|
||||
autoProvisioned: false
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
|
|
@ -204,7 +218,6 @@ export async function createOrgUser(
|
|||
.from(userOrgs)
|
||||
.where(eq(userOrgs.orgId, orgId));
|
||||
});
|
||||
|
||||
} else {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "User type is required")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { db, idp, idpOidcConfig } from "@server/db";
|
||||
import { roles, userOrgs, users } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
|
|
@ -25,10 +25,18 @@ async function queryUser(orgId: string, userId: string) {
|
|||
isOwner: userOrgs.isOwner,
|
||||
isAdmin: roles.isAdmin,
|
||||
twoFactorEnabled: users.twoFactorEnabled,
|
||||
autoProvisioned: userOrgs.autoProvisioned,
|
||||
idpId: users.idpId,
|
||||
idpName: idp.name,
|
||||
idpType: idp.type,
|
||||
idpVariant: idpOidcConfig.variant,
|
||||
idpAutoProvision: idp.autoProvision
|
||||
})
|
||||
.from(userOrgs)
|
||||
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
||||
.leftJoin(users, eq(userOrgs.userId, users.userId))
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
.leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId))
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||
.limit(1);
|
||||
return user;
|
||||
|
|
|
|||
|
|
@ -13,3 +13,4 @@ export * from "./removeInvitation";
|
|||
export * from "./createOrgUser";
|
||||
export * from "./adminUpdateUser2FA";
|
||||
export * from "./adminGetUser";
|
||||
export * from "./updateOrgUser";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { db, idpOidcConfig } from "@server/db";
|
||||
import { idp, roles, userOrgs, users } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
|
@ -50,12 +50,15 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
|
|||
isOwner: userOrgs.isOwner,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId,
|
||||
idpType: idp.type,
|
||||
idpVariant: idpOidcConfig.variant,
|
||||
twoFactorEnabled: users.twoFactorEnabled,
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
|
||||
.where(eq(userOrgs.orgId, orgId))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
|
|
|||
112
server/routers/user/updateOrgUser.ts
Normal file
112
server/routers/user/updateOrgUser.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, userOrgs } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
userId: z.string(),
|
||||
orgId: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
||||
const bodySchema = z
|
||||
.object({
|
||||
autoProvisioned: z.boolean().optional()
|
||||
})
|
||||
.strict()
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
message: "At least one field must be provided for update"
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/org/{orgId}/user/{userId}",
|
||||
description: "Update a user in an org.",
|
||||
tags: [OpenAPITags.Org, OpenAPITags.User],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function updateOrgUser(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { userId, orgId } = parsedParams.data;
|
||||
|
||||
const [existingUser] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!existingUser) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"User not found in this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const updateData = parsedBody.data;
|
||||
|
||||
const [updatedUser] = await db
|
||||
.update(userOrgs)
|
||||
.set({
|
||||
...updateData
|
||||
})
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||
.returning();
|
||||
|
||||
return response(res, {
|
||||
data: updatedUser,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Org user updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,8 @@ import {
|
|||
handleGetConfigMessage,
|
||||
handleDockerStatusMessage,
|
||||
handleDockerContainersMessage,
|
||||
handleNewtPingRequestMessage
|
||||
handleNewtPingRequestMessage,
|
||||
handleApplyBlueprintMessage
|
||||
} from "../newt";
|
||||
import {
|
||||
handleOlmRegisterMessage,
|
||||
|
|
@ -23,7 +24,8 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
|||
"olm/ping": handleOlmPingMessage,
|
||||
"newt/socket/status": handleDockerStatusMessage,
|
||||
"newt/socket/containers": handleDockerContainersMessage,
|
||||
"newt/ping/request": handleNewtPingRequestMessage
|
||||
"newt/ping/request": handleNewtPingRequestMessage,
|
||||
"newt/blueprint/apply": handleApplyBlueprintMessage,
|
||||
};
|
||||
|
||||
startOlmOfflineChecker(); // this is to handle the offline check for olms
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import m1 from "./scriptsPg/1.6.0";
|
|||
import m2 from "./scriptsPg/1.7.0";
|
||||
import m3 from "./scriptsPg/1.8.0";
|
||||
import m4 from "./scriptsPg/1.9.0";
|
||||
import m5 from "./scriptsPg/1.10.0";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
|
|
@ -18,7 +19,8 @@ const migrations = [
|
|||
{ version: "1.6.0", run: m1 },
|
||||
{ version: "1.7.0", run: m2 },
|
||||
{ version: "1.8.0", run: m3 },
|
||||
{ version: "1.9.0", run: m4 }
|
||||
{ version: "1.9.0", run: m4 },
|
||||
{ version: "1.10.0", run: m5 },
|
||||
// Add new migrations here as they are created
|
||||
] as {
|
||||
version: string;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ import m21 from "./scriptsSqlite/1.6.0";
|
|||
import m22 from "./scriptsSqlite/1.7.0";
|
||||
import m23 from "./scriptsSqlite/1.8.0";
|
||||
import m24 from "./scriptsSqlite/1.9.0";
|
||||
import m25 from "./scriptsSqlite/1.10.0";
|
||||
import m26 from "./scriptsSqlite/1.10.1";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
|
|
@ -51,6 +53,8 @@ const migrations = [
|
|||
{ version: "1.7.0", run: m22 },
|
||||
{ version: "1.8.0", run: m23 },
|
||||
{ version: "1.9.0", run: m24 },
|
||||
{ version: "1.10.0", run: m25 },
|
||||
{ version: "1.10.1", run: m26 },
|
||||
// Add new migrations here as they are created
|
||||
] as const;
|
||||
|
||||
|
|
|
|||
147
server/setup/scriptsPg/1.10.0.ts
Normal file
147
server/setup/scriptsPg/1.10.0.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { db } from "@server/db/pg/driver";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { __DIRNAME, APP_PATH } from "@server/lib/consts";
|
||||
import { readFileSync } from "fs";
|
||||
import path, { join } from "path";
|
||||
|
||||
const version = "1.10.0";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
try {
|
||||
const resources = await db.execute(sql`
|
||||
SELECT "resourceId" FROM "resources"
|
||||
`);
|
||||
|
||||
const siteResources = await db.execute(sql`
|
||||
SELECT "siteResourceId" FROM "siteResources"
|
||||
`);
|
||||
|
||||
await db.execute(sql`BEGIN`);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "exitNodes" ADD COLUMN "region" text;`
|
||||
);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "idpOidcConfig" ADD COLUMN "variant" text DEFAULT 'oidc' NOT NULL;`
|
||||
);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ADD COLUMN "niceId" text DEFAULT '' NOT NULL;`
|
||||
);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "siteResources" ADD COLUMN "niceId" text DEFAULT '' NOT NULL;`
|
||||
);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "userOrgs" ADD COLUMN "autoProvisioned" boolean DEFAULT false;`
|
||||
);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "targets" ADD COLUMN "pathMatchType" text;`
|
||||
);
|
||||
|
||||
await db.execute(sql`ALTER TABLE "targets" ADD COLUMN "path" text;`);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ADD COLUMN "headers" text;`
|
||||
);
|
||||
|
||||
const usedNiceIds: string[] = [];
|
||||
|
||||
for (const resource of resources.rows) {
|
||||
// Generate a unique name and ensure it's unique
|
||||
let niceId = "";
|
||||
let loops = 0;
|
||||
while (true) {
|
||||
if (loops > 100) {
|
||||
throw new Error("Could not generate a unique name");
|
||||
}
|
||||
|
||||
niceId = generateName();
|
||||
if (!usedNiceIds.includes(niceId)) {
|
||||
usedNiceIds.push(niceId);
|
||||
break;
|
||||
}
|
||||
loops++;
|
||||
}
|
||||
await db.execute(sql`
|
||||
UPDATE "resources" SET "niceId" = ${niceId} WHERE "resourceId" = ${resource.resourceId}
|
||||
`);
|
||||
}
|
||||
|
||||
for (const resource of siteResources.rows) {
|
||||
// Generate a unique name and ensure it's unique
|
||||
let niceId = "";
|
||||
let loops = 0;
|
||||
while (true) {
|
||||
if (loops > 100) {
|
||||
throw new Error("Could not generate a unique name");
|
||||
}
|
||||
|
||||
niceId = generateName();
|
||||
if (!usedNiceIds.includes(niceId)) {
|
||||
usedNiceIds.push(niceId);
|
||||
break;
|
||||
}
|
||||
loops++;
|
||||
}
|
||||
await db.execute(sql`
|
||||
UPDATE "siteResources" SET "niceId" = ${niceId} WHERE "siteResourceId" = ${resource.siteResourceId}
|
||||
`);
|
||||
}
|
||||
|
||||
// Handle auto-provisioned users for identity providers
|
||||
const autoProvisionIdps = await db.execute(sql`
|
||||
SELECT "idpId" FROM "idp" WHERE "autoProvision" = true
|
||||
`);
|
||||
|
||||
for (const idp of autoProvisionIdps.rows) {
|
||||
// Get all users with this identity provider
|
||||
const usersWithIdp = await db.execute(sql`
|
||||
SELECT "id" FROM "user" WHERE "idpId" = ${idp.idpId}
|
||||
`);
|
||||
|
||||
// Update userOrgs to set autoProvisioned to true for these users
|
||||
for (const user of usersWithIdp.rows) {
|
||||
await db.execute(sql`
|
||||
UPDATE "userOrgs" SET "autoProvisioned" = true WHERE "userId" = ${user.id}
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
await db.execute(sql`COMMIT`);
|
||||
console.log(`Migrated database`);
|
||||
} catch (e) {
|
||||
await db.execute(sql`ROLLBACK`);
|
||||
console.log("Failed to migrate db:", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const dev = process.env.ENVIRONMENT !== "prod";
|
||||
let file;
|
||||
if (!dev) {
|
||||
file = join(__DIRNAME, "names.json");
|
||||
} else {
|
||||
file = join("server/db/names.json");
|
||||
}
|
||||
export const names = JSON.parse(readFileSync(file, "utf-8"));
|
||||
|
||||
export function generateName(): string {
|
||||
const name = (
|
||||
names.descriptors[
|
||||
Math.floor(Math.random() * names.descriptors.length)
|
||||
] +
|
||||
"-" +
|
||||
names.animals[Math.floor(Math.random() * names.animals.length)]
|
||||
)
|
||||
.toLowerCase()
|
||||
.replace(/\s/g, "-");
|
||||
|
||||
// clean out any non-alphanumeric characters except for dashes
|
||||
return name.replace(/[^a-z0-9-]/g, "");
|
||||
}
|
||||
136
server/setup/scriptsSqlite/1.10.0.ts
Normal file
136
server/setup/scriptsSqlite/1.10.0.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { __DIRNAME, APP_PATH } from "@server/lib/consts";
|
||||
import Database from "better-sqlite3";
|
||||
import { readFileSync } from "fs";
|
||||
import path, { join } from "path";
|
||||
|
||||
const version = "1.10.0";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||
const db = new Database(location);
|
||||
|
||||
try {
|
||||
const resources = db
|
||||
.prepare(
|
||||
"SELECT resourceId FROM resources"
|
||||
)
|
||||
.all() as Array<{ resourceId: number }>;
|
||||
|
||||
const siteResources = db
|
||||
.prepare(
|
||||
"SELECT siteResourceId FROM siteResources"
|
||||
)
|
||||
.all() as Array<{ siteResourceId: number }>;
|
||||
|
||||
db.transaction(() => {
|
||||
db.exec(`
|
||||
ALTER TABLE 'exitNodes' ADD 'region' text;
|
||||
ALTER TABLE 'idpOidcConfig' ADD 'variant' text DEFAULT 'oidc' NOT NULL;
|
||||
ALTER TABLE 'resources' ADD 'niceId' text DEFAULT '' NOT NULL;
|
||||
ALTER TABLE 'siteResources' ADD 'niceId' text DEFAULT '' NOT NULL;
|
||||
ALTER TABLE 'userOrgs' ADD 'autoProvisioned' integer DEFAULT false;
|
||||
ALTER TABLE 'targets' ADD 'pathMatchType' text;
|
||||
ALTER TABLE 'targets' ADD 'path' text;
|
||||
ALTER TABLE 'resources' ADD 'headers' text;
|
||||
`); // this diverges from the schema a bit because the schema does not have a default on niceId but was required for the migration and I dont think it will effect much down the line...
|
||||
|
||||
const usedNiceIds: string[] = [];
|
||||
|
||||
for (const resourceId of resources) {
|
||||
// Generate a unique name and ensure it's unique
|
||||
let niceId = "";
|
||||
let loops = 0;
|
||||
while (true) {
|
||||
if (loops > 100) {
|
||||
throw new Error("Could not generate a unique name");
|
||||
}
|
||||
|
||||
niceId = generateName();
|
||||
if (!usedNiceIds.includes(niceId)) {
|
||||
usedNiceIds.push(niceId);
|
||||
break;
|
||||
}
|
||||
loops++;
|
||||
}
|
||||
db.prepare(
|
||||
`UPDATE resources SET niceId = ? WHERE resourceId = ?`
|
||||
).run(niceId, resourceId.resourceId);
|
||||
}
|
||||
|
||||
for (const resourceId of siteResources) {
|
||||
// Generate a unique name and ensure it's unique
|
||||
let niceId = "";
|
||||
let loops = 0;
|
||||
while (true) {
|
||||
if (loops > 100) {
|
||||
throw new Error("Could not generate a unique name");
|
||||
}
|
||||
|
||||
niceId = generateName();
|
||||
if (!usedNiceIds.includes(niceId)) {
|
||||
usedNiceIds.push(niceId);
|
||||
break;
|
||||
}
|
||||
loops++;
|
||||
}
|
||||
db.prepare(
|
||||
`UPDATE siteResources SET niceId = ? WHERE siteResourceId = ?`
|
||||
).run(niceId, resourceId.siteResourceId);
|
||||
}
|
||||
|
||||
// Handle auto-provisioned users for identity providers
|
||||
const autoProvisionIdps = db
|
||||
.prepare(
|
||||
"SELECT idpId FROM idp WHERE autoProvision = 1"
|
||||
)
|
||||
.all() as Array<{ idpId: number }>;
|
||||
|
||||
for (const idp of autoProvisionIdps) {
|
||||
// Get all users with this identity provider
|
||||
const usersWithIdp = db
|
||||
.prepare(
|
||||
"SELECT id FROM user WHERE idpId = ?"
|
||||
)
|
||||
.all(idp.idpId) as Array<{ id: string }>;
|
||||
|
||||
// Update userOrgs to set autoProvisioned to true for these users
|
||||
for (const user of usersWithIdp) {
|
||||
db.prepare(
|
||||
"UPDATE userOrgs SET autoProvisioned = 1 WHERE userId = ?"
|
||||
).run(user.id);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
console.log(`Migrated database`);
|
||||
} catch (e) {
|
||||
console.log("Failed to migrate db:", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const dev = process.env.ENVIRONMENT !== "prod";
|
||||
let file;
|
||||
if (!dev) {
|
||||
file = join(__DIRNAME, "names.json");
|
||||
} else {
|
||||
file = join("server/db/names.json");
|
||||
}
|
||||
export const names = JSON.parse(readFileSync(file, "utf-8"));
|
||||
|
||||
export function generateName(): string {
|
||||
const name = (
|
||||
names.descriptors[
|
||||
Math.floor(Math.random() * names.descriptors.length)
|
||||
] +
|
||||
"-" +
|
||||
names.animals[Math.floor(Math.random() * names.animals.length)]
|
||||
)
|
||||
.toLowerCase()
|
||||
.replace(/\s/g, "-");
|
||||
|
||||
// clean out any non-alphanumeric characters except for dashes
|
||||
return name.replace(/[^a-z0-9-]/g, "");
|
||||
}
|
||||
69
server/setup/scriptsSqlite/1.10.1.ts
Normal file
69
server/setup/scriptsSqlite/1.10.1.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { APP_PATH } from "@server/lib/consts";
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
|
||||
const version = "1.10.1";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||
const db = new Database(location);
|
||||
|
||||
try {
|
||||
db.pragma("foreign_keys = OFF");
|
||||
|
||||
db.transaction(() => {
|
||||
db.exec(`ALTER TABLE "targets" RENAME TO "targets_old";
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "targets" (
|
||||
"targetId" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"resourceId" INTEGER NOT NULL,
|
||||
"siteId" INTEGER NOT NULL,
|
||||
"ip" TEXT NOT NULL,
|
||||
"method" TEXT,
|
||||
"port" INTEGER NOT NULL,
|
||||
"internalPort" INTEGER,
|
||||
"enabled" INTEGER NOT NULL DEFAULT 1,
|
||||
"path" TEXT,
|
||||
"pathMatchType" TEXT,
|
||||
FOREIGN KEY ("resourceId") REFERENCES "resources"("resourceId") ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY ("siteId") REFERENCES "sites"("siteId") ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "targets" (
|
||||
"targetId",
|
||||
"resourceId",
|
||||
"siteId",
|
||||
"ip",
|
||||
"method",
|
||||
"port",
|
||||
"internalPort",
|
||||
"enabled",
|
||||
"path",
|
||||
"pathMatchType"
|
||||
)
|
||||
SELECT
|
||||
targetId,
|
||||
resourceId,
|
||||
siteId,
|
||||
ip,
|
||||
method,
|
||||
port,
|
||||
internalPort,
|
||||
enabled,
|
||||
path,
|
||||
pathMatchType
|
||||
FROM "targets_old";
|
||||
--> statement-breakpoint
|
||||
DROP TABLE "targets_old";`);
|
||||
})();
|
||||
|
||||
db.pragma("foreign_keys = ON");
|
||||
|
||||
console.log(`Migrated database`);
|
||||
} catch (e) {
|
||||
console.log("Failed to migrate db:", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { cache } from "react";
|
||||
import OrganizationLandingCard from "./OrganizationLandingCard";
|
||||
import MemberResourcesPortal from "./MemberResourcesPortal";
|
||||
import OrganizationLandingCard from "../../components/OrganizationLandingCard";
|
||||
import MemberResourcesPortal from "../../components/MemberResourcesPortal";
|
||||
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { AxiosResponse } from "axios";
|
||||
import InvitationsTable, { InvitationRow } from "./InvitationsTable";
|
||||
import InvitationsTable, { InvitationRow } from "../../../../../components/InvitationsTable";
|
||||
import { GetOrgResponse } from "@server/routers/org";
|
||||
import { cache } from "react";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav";
|
||||
import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
interface AccessLayoutProps {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{
|
||||
resourceId: number | string;
|
||||
orgId: string;
|
||||
}>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { GetOrgResponse } from "@server/routers/org";
|
|||
import { cache } from "react";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import { ListRolesResponse } from "@server/routers/role";
|
||||
import RolesTable, { RoleRow } from "./RolesTable";
|
||||
import RolesTable, { RoleRow } from "../../../../../components/RolesTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { InviteUserResponse } from "@server/routers/user";
|
||||
|
|
@ -41,6 +42,8 @@ import { formatAxiosError } from "@app/lib/api";
|
|||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import IdpTypeBadge from "@app/components/IdpTypeBadge";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
export default function AccessControlsPage() {
|
||||
const { orgUser: user } = userOrgUserContext();
|
||||
|
|
@ -56,14 +59,16 @@ export default function AccessControlsPage() {
|
|||
|
||||
const formSchema = z.object({
|
||||
username: z.string(),
|
||||
roleId: z.string().min(1, { message: t('accessRoleSelectPlease') })
|
||||
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }),
|
||||
autoProvisioned: z.boolean()
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
username: user.username!,
|
||||
roleId: user.roleId?.toString()
|
||||
roleId: user.roleId?.toString(),
|
||||
autoProvisioned: user.autoProvisioned || false
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -75,10 +80,10 @@ export default function AccessControlsPage() {
|
|||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('accessRoleErrorFetch'),
|
||||
title: t("accessRoleErrorFetch"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('accessRoleErrorFetchDescription')
|
||||
t("accessRoleErrorFetchDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
|
@ -91,31 +96,38 @@ export default function AccessControlsPage() {
|
|||
fetchRoles();
|
||||
|
||||
form.setValue("roleId", user.roleId.toString());
|
||||
form.setValue("autoProvisioned", user.autoProvisioned || false);
|
||||
}, []);
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setLoading(true);
|
||||
|
||||
const res = await api
|
||||
.post<
|
||||
AxiosResponse<InviteUserResponse>
|
||||
>(`/role/${values.roleId}/add/${user.userId}`)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('accessRoleErrorAdd'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('accessRoleErrorAddDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
try {
|
||||
// Execute both API calls simultaneously
|
||||
const [roleRes, userRes] = await Promise.all([
|
||||
api.post<AxiosResponse<InviteUserResponse>>(
|
||||
`/role/${values.roleId}/add/${user.userId}`
|
||||
),
|
||||
api.post(`/org/${orgId}/user/${user.userId}`, {
|
||||
autoProvisioned: values.autoProvisioned
|
||||
})
|
||||
]);
|
||||
|
||||
if (res && res.status === 200) {
|
||||
if (roleRes.status === 200 && userRes.status === 200) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t("userSaved"),
|
||||
description: t("userSavedDescription")
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t('userSaved'),
|
||||
description: t('userSavedDescription')
|
||||
variant: "destructive",
|
||||
title: t("accessRoleErrorAdd"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("accessRoleErrorAddDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -126,9 +138,11 @@ export default function AccessControlsPage() {
|
|||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>{t('accessControls')}</SettingsSectionTitle>
|
||||
<SettingsSectionTitle>
|
||||
{t("accessControls")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t('accessControlsDescription')}
|
||||
{t("accessControlsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
|
|
@ -140,19 +154,49 @@ export default function AccessControlsPage() {
|
|||
className="space-y-4"
|
||||
id="access-controls-form"
|
||||
>
|
||||
{/* IDP Type Display */}
|
||||
{user.type !== UserType.Internal &&
|
||||
user.idpType && (
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("idp")}:
|
||||
</span>
|
||||
<IdpTypeBadge
|
||||
type={user.idpType}
|
||||
variant={
|
||||
user.idpVariant || undefined
|
||||
}
|
||||
name={user.idpName || undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('role')}</FormLabel>
|
||||
<FormLabel>{t("role")}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
// If auto provision is enabled, set it to false when role changes
|
||||
if (user.idpAutoProvision) {
|
||||
form.setValue(
|
||||
"autoProvisioned",
|
||||
false
|
||||
);
|
||||
}
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('accessRoleSelect')} />
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"accessRoleSelect"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
|
|
@ -170,6 +214,35 @@ export default function AccessControlsPage() {
|
|||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{user.idpAutoProvision && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="autoProvisioned"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>
|
||||
{t("autoProvisioned")}
|
||||
</FormLabel>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"autoProvisionedDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
|
|
@ -182,7 +255,7 @@ export default function AccessControlsPage() {
|
|||
disabled={loading}
|
||||
form="access-controls-form"
|
||||
>
|
||||
{t('accessControlsSubmit')}
|
||||
{t("accessControlsSubmit")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import { Checkbox } from "@app/components/ui/checkbox";
|
|||
import { ListIdpsResponse } from "@server/routers/idp";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
import Image from "next/image";
|
||||
|
||||
type UserType = "internal" | "oidc";
|
||||
|
||||
|
|
@ -53,6 +54,17 @@ interface IdpOption {
|
|||
idpId: number;
|
||||
name: string;
|
||||
type: string;
|
||||
variant: string | null;
|
||||
}
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
disabled: boolean;
|
||||
icon?: React.ReactNode;
|
||||
idpId?: number;
|
||||
variant?: string | null;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
|
|
@ -62,14 +74,14 @@ export default function Page() {
|
|||
const api = createApiClient({ env });
|
||||
const t = useTranslations();
|
||||
|
||||
const [userType, setUserType] = useState<UserType | null>("internal");
|
||||
const [selectedOption, setSelectedOption] = useState<string | null>("internal");
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expiresInDays, setExpiresInDays] = useState(1);
|
||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
||||
const [idps, setIdps] = useState<IdpOption[]>([]);
|
||||
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
|
||||
const [selectedIdp, setSelectedIdp] = useState<IdpOption | null>(null);
|
||||
const [userOptions, setUserOptions] = useState<UserOption[]>([]);
|
||||
const [dataLoaded, setDataLoaded] = useState(false);
|
||||
|
||||
const internalFormSchema = z.object({
|
||||
|
|
@ -80,7 +92,13 @@ export default function Page() {
|
|||
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
|
||||
});
|
||||
|
||||
const externalFormSchema = z.object({
|
||||
const googleAzureFormSchema = z.object({
|
||||
email: z.string().email({ message: t("emailInvalid") }),
|
||||
name: z.string().optional(),
|
||||
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
|
||||
});
|
||||
|
||||
const genericOidcFormSchema = z.object({
|
||||
username: z.string().min(1, { message: t("usernameRequired") }),
|
||||
email: z
|
||||
.string()
|
||||
|
|
@ -88,19 +106,51 @@ export default function Page() {
|
|||
.optional()
|
||||
.or(z.literal("")),
|
||||
name: z.string().optional(),
|
||||
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }),
|
||||
idpId: z.string().min(1, { message: t("idpSelectPlease") })
|
||||
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
|
||||
});
|
||||
|
||||
const formatIdpType = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case "oidc":
|
||||
return t("idpGenericOidc");
|
||||
case "google":
|
||||
return t("idpGoogleDescription");
|
||||
case "azure":
|
||||
return t("idpAzureDescription");
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
const getIdpIcon = (variant: string | null) => {
|
||||
if (!variant) return null;
|
||||
|
||||
switch (variant.toLowerCase()) {
|
||||
case "google":
|
||||
return (
|
||||
<Image
|
||||
src="/idp/google.png"
|
||||
alt={t("idpGoogleAlt")}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded"
|
||||
/>
|
||||
);
|
||||
case "azure":
|
||||
return (
|
||||
<Image
|
||||
src="/idp/azure.png"
|
||||
alt={t("idpAzureAlt")}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const validFor = [
|
||||
{ hours: 24, name: t("day", { count: 1 }) },
|
||||
{ hours: 48, name: t("day", { count: 2 }) },
|
||||
|
|
@ -120,45 +170,39 @@ export default function Page() {
|
|||
}
|
||||
});
|
||||
|
||||
const externalForm = useForm<z.infer<typeof externalFormSchema>>({
|
||||
resolver: zodResolver(externalFormSchema),
|
||||
const googleAzureForm = useForm<z.infer<typeof googleAzureFormSchema>>({
|
||||
resolver: zodResolver(googleAzureFormSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
name: "",
|
||||
roleId: ""
|
||||
}
|
||||
});
|
||||
|
||||
const genericOidcForm = useForm<z.infer<typeof genericOidcFormSchema>>({
|
||||
resolver: zodResolver(genericOidcFormSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
email: "",
|
||||
name: "",
|
||||
roleId: "",
|
||||
idpId: ""
|
||||
roleId: ""
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (userType === "internal") {
|
||||
if (selectedOption === "internal") {
|
||||
setSendEmail(env.email.emailEnabled);
|
||||
internalForm.reset();
|
||||
setInviteLink(null);
|
||||
setExpiresInDays(1);
|
||||
} else if (userType === "oidc") {
|
||||
externalForm.reset();
|
||||
} else if (selectedOption && selectedOption !== "internal") {
|
||||
googleAzureForm.reset();
|
||||
genericOidcForm.reset();
|
||||
}
|
||||
}, [userType, env.email.emailEnabled, internalForm, externalForm]);
|
||||
|
||||
const [userTypes, setUserTypes] = useState<StrategyOption<string>[]>([
|
||||
{
|
||||
id: "internal",
|
||||
title: t("userTypeInternal"),
|
||||
description: t("userTypeInternalDescription"),
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
id: "oidc",
|
||||
title: t("userTypeExternal"),
|
||||
description: t("userTypeExternalDescription"),
|
||||
disabled: true
|
||||
}
|
||||
]);
|
||||
}, [selectedOption, env.email.emailEnabled, internalForm, googleAzureForm, genericOidcForm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userType) {
|
||||
if (!selectedOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -199,20 +243,6 @@ export default function Page() {
|
|||
|
||||
if (res?.status === 200) {
|
||||
setIdps(res.data.data.idps);
|
||||
|
||||
if (res.data.data.idps.length) {
|
||||
setUserTypes((prev) =>
|
||||
prev.map((type) => {
|
||||
if (type.id === "oidc") {
|
||||
return {
|
||||
...type,
|
||||
disabled: false
|
||||
};
|
||||
}
|
||||
return type;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -226,6 +256,33 @@ export default function Page() {
|
|||
fetchInitialData();
|
||||
}, []);
|
||||
|
||||
// Build user options when IDPs are loaded
|
||||
useEffect(() => {
|
||||
const options: UserOption[] = [
|
||||
{
|
||||
id: "internal",
|
||||
title: t("userTypeInternal"),
|
||||
description: t("userTypeInternalDescription"),
|
||||
disabled: false
|
||||
}
|
||||
];
|
||||
|
||||
// Add IDP options
|
||||
idps.forEach((idp) => {
|
||||
options.push({
|
||||
id: `idp-${idp.idpId}`,
|
||||
title: idp.name,
|
||||
description: formatIdpType(idp.variant || idp.type),
|
||||
disabled: false,
|
||||
icon: getIdpIcon(idp.variant),
|
||||
idpId: idp.idpId,
|
||||
variant: idp.variant
|
||||
});
|
||||
});
|
||||
|
||||
setUserOptions(options);
|
||||
}, [idps, t]);
|
||||
|
||||
async function onSubmitInternal(
|
||||
values: z.infer<typeof internalFormSchema>
|
||||
) {
|
||||
|
|
@ -274,9 +331,52 @@ export default function Page() {
|
|||
setLoading(false);
|
||||
}
|
||||
|
||||
async function onSubmitExternal(
|
||||
values: z.infer<typeof externalFormSchema>
|
||||
async function onSubmitGoogleAzure(
|
||||
values: z.infer<typeof googleAzureFormSchema>
|
||||
) {
|
||||
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
|
||||
if (!selectedUserOption?.idpId) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const res = await api
|
||||
.put(`/org/${orgId}/user`, {
|
||||
username: values.email, // Use email as username for Google/Azure
|
||||
email: values.email,
|
||||
name: values.name,
|
||||
type: "oidc",
|
||||
idpId: selectedUserOption.idpId,
|
||||
roleId: parseInt(values.roleId)
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("userErrorCreate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("userErrorCreateDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 201) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t("userCreated"),
|
||||
description: t("userCreatedDescription")
|
||||
});
|
||||
router.push(`/${orgId}/settings/access/users`);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function onSubmitGenericOidc(
|
||||
values: z.infer<typeof genericOidcFormSchema>
|
||||
) {
|
||||
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
|
||||
if (!selectedUserOption?.idpId) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const res = await api
|
||||
|
|
@ -285,7 +385,7 @@ export default function Page() {
|
|||
email: values.email,
|
||||
name: values.name,
|
||||
type: "oidc",
|
||||
idpId: parseInt(values.idpId),
|
||||
idpId: selectedUserOption.idpId,
|
||||
roleId: parseInt(values.roleId)
|
||||
})
|
||||
.catch((e) => {
|
||||
|
|
@ -330,7 +430,7 @@ export default function Page() {
|
|||
|
||||
<div>
|
||||
<SettingsContainer>
|
||||
{!inviteLink && build !== "saas" ? (
|
||||
{!inviteLink && build !== "saas" && dataLoaded ? (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
|
|
@ -342,15 +442,15 @@ export default function Page() {
|
|||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<StrategySelect
|
||||
options={userTypes}
|
||||
defaultValue={userType || undefined}
|
||||
options={userOptions}
|
||||
defaultValue={selectedOption || undefined}
|
||||
onChange={(value) => {
|
||||
setUserType(value as UserType);
|
||||
setSelectedOption(value);
|
||||
if (value === "internal") {
|
||||
internalForm.reset();
|
||||
} else if (value === "oidc") {
|
||||
externalForm.reset();
|
||||
setSelectedIdp(null);
|
||||
} else {
|
||||
googleAzureForm.reset();
|
||||
genericOidcForm.reset();
|
||||
}
|
||||
}}
|
||||
cols={2}
|
||||
|
|
@ -359,7 +459,7 @@ export default function Page() {
|
|||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
{userType === "internal" && dataLoaded && (
|
||||
{selectedOption === "internal" && dataLoaded && (
|
||||
<>
|
||||
{!inviteLink ? (
|
||||
<SettingsSection>
|
||||
|
|
@ -564,71 +664,7 @@ export default function Page() {
|
|||
</>
|
||||
)}
|
||||
|
||||
{userType !== "internal" && dataLoaded && (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("idpTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("idpSelect")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{idps.length === 0 ? (
|
||||
<p className="text-muted-foreground">
|
||||
{t("idpNotConfigured")}
|
||||
</p>
|
||||
) : (
|
||||
<Form {...externalForm}>
|
||||
<FormField
|
||||
control={externalForm.control}
|
||||
name="idpId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<StrategySelect
|
||||
options={idps.map(
|
||||
(idp) => ({
|
||||
id: idp.idpId.toString(),
|
||||
title: idp.name,
|
||||
description:
|
||||
formatIdpType(
|
||||
idp.type
|
||||
)
|
||||
})
|
||||
)}
|
||||
defaultValue={
|
||||
field.value
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) => {
|
||||
field.onChange(
|
||||
value
|
||||
);
|
||||
const idp =
|
||||
idps.find(
|
||||
(idp) =>
|
||||
idp.idpId.toString() ===
|
||||
value
|
||||
);
|
||||
setSelectedIdp(
|
||||
idp || null
|
||||
);
|
||||
}}
|
||||
cols={2}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
{idps.length > 0 && (
|
||||
{selectedOption && selectedOption !== "internal" && dataLoaded && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
|
|
@ -640,144 +676,206 @@ export default function Page() {
|
|||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...externalForm}>
|
||||
<form
|
||||
onSubmit={externalForm.handleSubmit(
|
||||
onSubmitExternal
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="create-user-form"
|
||||
>
|
||||
<FormField
|
||||
control={
|
||||
externalForm.control
|
||||
}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"username"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t(
|
||||
"usernameUniq"
|
||||
)}
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
{/* Google/Azure Form */}
|
||||
{(() => {
|
||||
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
|
||||
return selectedUserOption?.variant === "google" || selectedUserOption?.variant === "azure";
|
||||
})() && (
|
||||
<Form {...googleAzureForm}>
|
||||
<form
|
||||
onSubmit={googleAzureForm.handleSubmit(
|
||||
onSubmitGoogleAzure
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={
|
||||
externalForm.control
|
||||
}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"emailOptional"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={
|
||||
externalForm.control
|
||||
}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"nameOptional"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={
|
||||
externalForm.control
|
||||
}
|
||||
name="roleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("role")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
>
|
||||
className="space-y-4"
|
||||
id="create-user-form"
|
||||
>
|
||||
<FormField
|
||||
control={googleAzureForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("email")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"accessRoleSelect"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{roles.map(
|
||||
(
|
||||
role
|
||||
) => (
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={googleAzureForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("nameOptional")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={googleAzureForm.control}
|
||||
name="roleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("role")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={t("accessRoleSelect")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem
|
||||
key={
|
||||
role.roleId
|
||||
}
|
||||
key={role.roleId}
|
||||
value={role.roleId.toString()}
|
||||
>
|
||||
{
|
||||
role.name
|
||||
}
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{/* Generic OIDC Form */}
|
||||
{(() => {
|
||||
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
|
||||
return selectedUserOption?.variant !== "google" && selectedUserOption?.variant !== "azure";
|
||||
})() && (
|
||||
<Form {...genericOidcForm}>
|
||||
<form
|
||||
onSubmit={genericOidcForm.handleSubmit(
|
||||
onSubmitGenericOidc
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
className="space-y-4"
|
||||
id="create-user-form"
|
||||
>
|
||||
<FormField
|
||||
control={genericOidcForm.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("username")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("usernameUniq")}
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={genericOidcForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("emailOptional")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={genericOidcForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("nameOptional")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={genericOidcForm.control}
|
||||
name="roleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("role")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={t("accessRoleSelect")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem
|
||||
key={role.roleId}
|
||||
value={role.roleId.toString()}
|
||||
>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-8">
|
||||
{userType && dataLoaded && (
|
||||
{selectedOption && dataLoaded && (
|
||||
<Button
|
||||
type={inviteLink ? "button" : "submit"}
|
||||
form={inviteLink ? undefined : "create-user-form"}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ import { internal } from "@app/lib/api";
|
|||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { ListUsersResponse } from "@server/routers/user";
|
||||
import { AxiosResponse } from "axios";
|
||||
import UsersTable, { UserRow } from "./UsersTable";
|
||||
import UsersTable, { UserRow } from "../../../../../components/UsersTable";
|
||||
import { GetOrgResponse } from "@server/routers/org";
|
||||
import { cache } from "react";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav";
|
||||
import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
|
|
@ -77,6 +77,7 @@ export default async function UsersPage(props: UsersPageProps) {
|
|||
name: user.name,
|
||||
email: user.email,
|
||||
type: user.type,
|
||||
idpVariant: user.idpVariant,
|
||||
idpId: user.idpId,
|
||||
idpName: user.idpName || t('idpNameInternal'),
|
||||
status: t('userConfirmed'),
|
||||
|
|
|
|||
|
|
@ -210,6 +210,11 @@ export default function Page() {
|
|||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault(); // block default enter refresh
|
||||
}
|
||||
}}
|
||||
className="space-y-4"
|
||||
id="create-site-form"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { internal } from "@app/lib/api";
|
|||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { AxiosResponse } from "axios";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import OrgApiKeysTable, { OrgApiKeyRow } from "./OrgApiKeysTable";
|
||||
import OrgApiKeysTable, { OrgApiKeyRow } from "../../../../components/OrgApiKeysTable";
|
||||
import { ListOrgApiKeysResponse } from "@server/routers/apiKeys";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ export const dynamic = "force-dynamic";
|
|||
export default async function ApiKeysPage(props: ApiKeyPageProps) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
|
||||
let apiKeys: ListOrgApiKeysResponse["apiKeys"] = [];
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<ListOrgApiKeysResponse>>(
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@ import { AxiosResponse } from "axios";
|
|||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { GetClientResponse } from "@server/routers/client";
|
||||
import ClientInfoCard from "./ClientInfoCard";
|
||||
import ClientInfoCard from "../../../../../components/ClientInfoCard";
|
||||
import ClientProvider from "@app/providers/ClientProvider";
|
||||
import { redirect } from "next/navigation";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
|
||||
type SettingsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ clientId: number; orgId: string }>;
|
||||
params: Promise<{ clientId: number | string; orgId: string }>;
|
||||
}
|
||||
|
||||
export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function ClientPage(props: {
|
||||
params: Promise<{ orgId: string; clientId: number }>;
|
||||
params: Promise<{ orgId: string; clientId: number | string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
redirect(`/${params.orgId}/settings/clients/${params.clientId}/general`);
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ import {
|
|||
FaFreebsd,
|
||||
FaWindows
|
||||
} from "react-icons/fa";
|
||||
import {
|
||||
SiNixos,
|
||||
SiKubernetes
|
||||
} from "react-icons/si";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
|
@ -248,10 +252,14 @@ export default function Page() {
|
|||
return <FaApple className="h-4 w-4 mr-2" />;
|
||||
case "docker":
|
||||
return <FaDocker className="h-4 w-4 mr-2" />;
|
||||
case "kubernetes":
|
||||
return <SiKubernetes className="h-4 w-4 mr-2" />;
|
||||
case "podman":
|
||||
return <FaCubes className="h-4 w-4 mr-2" />;
|
||||
case "freebsd":
|
||||
return <FaFreebsd className="h-4 w-4 mr-2" />;
|
||||
case "nixos":
|
||||
return <SiNixos className="h-4 w-4 mr-2" />;
|
||||
default:
|
||||
return <Terminal className="h-4 w-4 mr-2" />;
|
||||
}
|
||||
|
|
@ -440,6 +448,11 @@ export default function Page() {
|
|||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault(); // block default enter refresh
|
||||
}
|
||||
}}
|
||||
className="space-y-4"
|
||||
id="create-client-form"
|
||||
>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue