mirror of
https://github.com/fosrl/pangolin.git
synced 2025-12-07 00:15:43 +00:00
Compare commits
123 Commits
1.0.0-beta
...
1.0.0-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8445e83c7c | ||
|
|
89a59b25fc | ||
|
|
57a37a01ce | ||
|
|
f8add1f098 | ||
|
|
0bd0cc76fb | ||
|
|
06e4fbac68 | ||
|
|
e82df67063 | ||
|
|
84f94bb727 | ||
|
|
20f1a6372b | ||
|
|
06c434a5ea | ||
|
|
b83dadb14b | ||
|
|
492e53edf3 | ||
|
|
3d9557b65c | ||
|
|
332804ed71 | ||
|
|
de70c62ea8 | ||
|
|
e4789c6b08 | ||
|
|
ec9d02a735 | ||
|
|
ae73a2f3f4 | ||
|
|
d8183bfd0d | ||
|
|
e11748fe30 | ||
|
|
ccbe56e110 | ||
|
|
ff37e07ce6 | ||
|
|
f59f0ee57d | ||
|
|
372932985d | ||
|
|
c877bb1187 | ||
|
|
5f95500b6f | ||
|
|
3194dc56eb | ||
|
|
e49fb646b0 | ||
|
|
fd11fb81d6 | ||
|
|
82f990eb8b | ||
|
|
851bedb2e5 | ||
|
|
e6c42e9610 | ||
|
|
d3d523b2b8 | ||
|
|
532d3696c2 | ||
|
|
dabd4a055c | ||
|
|
7bf820a4bf | ||
|
|
b862e1aeef | ||
|
|
bdee036ab4 | ||
|
|
62238948e0 | ||
|
|
489f6bed17 | ||
|
|
6aa4908446 | ||
|
|
d5a220a004 | ||
|
|
a418195b28 | ||
|
|
2ff6d1d117 | ||
|
|
8dd30c88ab | ||
|
|
7797c6c770 | ||
|
|
40922fedb8 | ||
|
|
4c1366ef91 | ||
|
|
f61d442989 | ||
|
|
60449afca5 | ||
|
|
b1702bf99a | ||
|
|
a35e24bc0e | ||
|
|
c230e034cf | ||
|
|
06ceff7427 | ||
|
|
81c4199e87 | ||
|
|
19273ddbd5 | ||
|
|
fdf1dfdeba | ||
|
|
f14ecf50e4 | ||
|
|
c244ef387b | ||
|
|
8165051dd8 | ||
|
|
a7b8ffaf9f | ||
|
|
6fba13c8d1 | ||
|
|
3c99fbb1ef | ||
|
|
5b44ffa2fb | ||
|
|
6e6992e19f | ||
|
|
4bce210ff5 | ||
|
|
bbc1a9eac4 | ||
|
|
5e92aebd20 | ||
|
|
2428738fa6 | ||
|
|
d22c7826fe | ||
|
|
34e3fe690d | ||
|
|
c415ceef8d | ||
|
|
73798f9e61 | ||
|
|
9694261f3e | ||
|
|
874c67345e | ||
|
|
42434ca832 | ||
|
|
4a6da91faf | ||
|
|
8f96d0795c | ||
|
|
da3c8823f8 | ||
|
|
3cd20cab55 | ||
|
|
b1fa980f56 | ||
|
|
ef0bc9a764 | ||
|
|
dc2ec5b73b | ||
|
|
d8a089fbc2 | ||
|
|
00a0d89d6c | ||
|
|
2f49be69fe | ||
|
|
b92639647a | ||
|
|
befdc3a002 | ||
|
|
3c7025a327 | ||
|
|
58a084426b | ||
|
|
d070415515 | ||
|
|
3fa7132534 | ||
|
|
feeeba5cee | ||
|
|
9e5d5e8990 | ||
|
|
c51f1cb6a2 | ||
|
|
786551d86a | ||
|
|
0e73365106 | ||
|
|
b6963a9c35 | ||
|
|
bc0b467f1a | ||
|
|
7cf798851c | ||
|
|
e475c1ea50 | ||
|
|
0840c166ab | ||
|
|
65a537a670 | ||
|
|
a7c99b016c | ||
|
|
6a8132546e | ||
|
|
94ce5edc61 | ||
|
|
889f8e1394 | ||
|
|
9d36198459 | ||
|
|
673635a585 | ||
|
|
53660a163c | ||
|
|
b5420a40ab | ||
|
|
962c5fb886 | ||
|
|
7d6dd9e9fd | ||
|
|
dc9b1f1efd | ||
|
|
3257c39fca | ||
|
|
8b43c6f9c5 | ||
|
|
8b5cac40e0 | ||
|
|
722b877ea5 | ||
|
|
a9477d7eb9 | ||
|
|
bb5573a8f4 | ||
|
|
81571a8fb7 | ||
|
|
57cd776c34 | ||
|
|
a039168217 |
11
.github/workflows/cicd.yml
vendored
11
.github/workflows/cicd.yml
vendored
@@ -35,13 +35,8 @@ jobs:
|
||||
- name: Update version in package.json
|
||||
run: |
|
||||
TAG=${{ env.TAG }}
|
||||
if [ -f package.json ]; then
|
||||
jq --arg version "$TAG" '.version = $version' package.json > package.tmp.json && mv package.tmp.json package.json
|
||||
echo "Updated package.json with version $TAG"
|
||||
else
|
||||
echo "package.json not found"
|
||||
fi
|
||||
cat package.json
|
||||
sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts
|
||||
cat server/lib/consts.ts
|
||||
|
||||
- name: Pull latest Gerbil version
|
||||
id: get-gerbil-tag
|
||||
@@ -69,7 +64,7 @@ jobs:
|
||||
- name: Build installer
|
||||
working-directory: install
|
||||
run: |
|
||||
make release
|
||||
make go-build-release
|
||||
|
||||
- name: Upload artifacts from /install/bin
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
3
Makefile
3
Makefile
@@ -12,9 +12,6 @@ build-arm:
|
||||
build-x86:
|
||||
docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest .
|
||||
|
||||
build-x86-ecr:
|
||||
docker buildx build --platform linux/amd64 -t 216989133116.dkr.ecr.us-east-1.amazonaws.com/pangolin:latest --push .
|
||||
|
||||
build:
|
||||
docker build -t fosrl/pangolin:latest .
|
||||
|
||||
|
||||
68
README.md
68
README.md
@@ -1,4 +1,5 @@
|
||||
# Pangolin
|
||||
<div align="center">
|
||||
<h2 align="center"><a href="https://fossorial.io"><img alt="pangolin" src="public/logo//word_mark.png" width="400" /></a></h2>
|
||||
|
||||
[](https://docs.fossorial.io/)
|
||||
[](https://hub.docker.com/r/fosrl/pangolin)
|
||||
@@ -6,19 +7,28 @@
|
||||
[](https://discord.gg/HCJR8Xhme4)
|
||||
[](https://www.youtube.com/@fossorial-app)
|
||||
|
||||
Pangolin is a self-hosted tunneled reverse proxy management server with identity and access management, designed to securely expose private resources through use with the Traefik reverse proxy and WireGuard tunnel clients like Newt. With Pangolin, you retain full control over your infrastructure while providing a user-friendly and feature-rich solution for managing proxies, authentication, and access, and simplifying complex network setups, all with a clean and simple UI.
|
||||
</div>
|
||||
|
||||
### Installation and Documentation
|
||||
<h3 align="center">Tunneled Mesh Reverse Proxy Server with Access Control</h3>
|
||||
<div align="center">
|
||||
|
||||
- [Installation Instructions](https://docs.fossorial.io/Getting%20Started/quick-install)
|
||||
- [Full Documentation](https://docs.fossorial.io)
|
||||
_Your own self-hosted zero trust tunnel._
|
||||
|
||||
### Authors and Maintainers
|
||||
</div>
|
||||
|
||||
- [Milo Schwartz](https://github.com/miloschwartz)
|
||||
- [Owen Schwartz](https://github.com/oschwartz10612)
|
||||
<div align="center">
|
||||
<h5>
|
||||
<a href="https://docs.fossorial.io/Getting%20Started/quick-install">
|
||||
Install Guide
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://docs.fossorial.io">
|
||||
Full Documentation
|
||||
</a>
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
## Preview
|
||||
Pangolin is a self-hosted tunneled reverse proxy server with identity and access control, designed to securely expose private resources on distributed networks. Acting as a central hub, it connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports.
|
||||
|
||||
<img src="public/screenshots/sites.png" alt="Preview"/>
|
||||
|
||||
@@ -28,16 +38,18 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
|
||||
|
||||
### Reverse Proxy Through WireGuard Tunnel
|
||||
|
||||
- Expose private resources on your network **without opening ports**.
|
||||
- Expose private resources on your network **without opening ports** (firewall punching).
|
||||
- Secure and easy to configure site-to-site connectivity via a custom **user space WireGuard client**, [Newt](https://github.com/fosrl/newt).
|
||||
- Built-in support for any WireGuard client.
|
||||
- Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/).
|
||||
- Support for HTTP/HTTPS and **raw TCP/UDP services**.
|
||||
- Load balancing.
|
||||
|
||||
### Identity & Access Management
|
||||
|
||||
- Centralized authentication system using platform SSO. **Users will only have to manage one login.**
|
||||
- Totp with backup codes for two-factor authentication.
|
||||
- **Define access control rules for IPs, IP ranges, and URL paths per resource.**
|
||||
- TOTP with backup codes for two-factor authentication.
|
||||
- Create organizations, each with multiple sites, users, and roles.
|
||||
- **Role-based access control** to manage resource access permissions.
|
||||
- Additional authentication options include:
|
||||
@@ -55,20 +67,18 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
|
||||
|
||||
### Easy Deployment
|
||||
|
||||
- Run on any cloud provider or on-premises.
|
||||
- Docker Compose based setup for simplified deployment.
|
||||
- Future-proof installation script for streamlined setup and feature additions.
|
||||
- Run on any VPS.
|
||||
- Use your preferred WireGuard client to connect, or use Newt, our custom user space client for the best experience.
|
||||
|
||||
### Modular Design
|
||||
|
||||
- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [Fail2Ban](https://plugins.traefik.io/plugins/628c9ebcffc0cd18356a979f/fail2-ban) or [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin), which integrate seamlessly.
|
||||
- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [Fail2Ban](https://plugins.traefik.io/plugins/628c9ebcffc0cd18356a979f/fail2-ban) or [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin).
|
||||
- Attach as many sites to the central server as you wish.
|
||||
|
||||
## Screenshots
|
||||
|
||||
Pangolin has a straightforward and simple dashboard UI:
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
@@ -94,22 +104,27 @@ Pangolin has a straightforward and simple dashboard UI:
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## Workflow Example
|
||||
|
||||
### Deployment and Usage Example
|
||||
## Deployment and Usage Example
|
||||
|
||||
1. **Deploy the Central Server**:
|
||||
|
||||
- Deploy the Docker Compose stack containing Pangolin, Gerbil, and Traefik onto a VPS hosted on a cloud platform like Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs.
|
||||
- Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like RackNerd, Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs.
|
||||
|
||||
> [!TIP]
|
||||
> Many of our users have had a great experience with [RackNerd](https://my.racknerd.com/aff.php?aff=13788). Depending on promotions, you can likely get a **VPS with 1 vCPU, 1GB RAM, and ~20GB SSD for just around $12/year**. That's a great deal!
|
||||
> We are part of the [RackNerd](https://my.racknerd.com/aff.php?aff=13788) affiliate program, so if you sign up using [our link](https://my.racknerd.com/aff.php?aff=13788), we receive a small commission which helps us maintain the project and keep it free for everyone.
|
||||
|
||||
2. **Domain Configuration**:
|
||||
|
||||
- Point your domain name to the VPS and configure Pangolin with your preferred settings.
|
||||
|
||||
3. **Connect Private Sites**:
|
||||
|
||||
- Install Newt or use another WireGuard client on private sites.
|
||||
- Automatically establish a connection from these sites to the central server.
|
||||
|
||||
4. **Configure Users & Roles**
|
||||
|
||||
- Define organizations and invite users.
|
||||
- Implement user- or role-based permissions to control resource access.
|
||||
|
||||
@@ -121,17 +136,22 @@ Pangolin has a straightforward and simple dashboard UI:
|
||||
|
||||
## Similar Projects and Inspirations
|
||||
|
||||
Pangolin was inspired by several existing projects and concepts:
|
||||
|
||||
- **Cloudflare Tunnels**:
|
||||
**Cloudflare Tunnels**:
|
||||
A similar approach to proxying private resources securely, but Pangolin is a self-hosted alternative, giving you full control over your infrastructure.
|
||||
|
||||
- **Authentik and Authelia**:
|
||||
**Authentik and Authelia**:
|
||||
These projects inspired Pangolin’s centralized authentication system for proxies, enabling robust user and role management.
|
||||
|
||||
## Project Development / Roadmap
|
||||
|
||||
> [!NOTE]
|
||||
> Pangolin is under heavy development. The roadmap is subject to change as we fix bugs, add new features, and make improvements.
|
||||
|
||||
View the [project board](https://github.com/orgs/fosrl/projects/1) for more detailed info.
|
||||
|
||||
## Licensing
|
||||
|
||||
Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us.
|
||||
Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io).
|
||||
|
||||
## Contributions
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
app:
|
||||
dashboard_url: "http://localhost:3002"
|
||||
base_domain: "localhost"
|
||||
log_level: "info"
|
||||
save_logs: false
|
||||
|
||||
domains:
|
||||
domain1:
|
||||
base_domain: "example.com"
|
||||
cert_resolver: "letsencrypt"
|
||||
|
||||
server:
|
||||
external_port: 3000
|
||||
internal_port: 3001
|
||||
@@ -14,7 +18,6 @@ server:
|
||||
resource_session_request_param: "p_session_request"
|
||||
|
||||
traefik:
|
||||
cert_resolver: "letsencrypt"
|
||||
http_entrypoint: "web"
|
||||
https_entrypoint: "websecure"
|
||||
|
||||
@@ -41,3 +44,4 @@ flags:
|
||||
disable_signup_without_invite: true
|
||||
disable_user_create_org: true
|
||||
allow_raw_resources: true
|
||||
allow_base_domain_resources: true
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
version: "3.7"
|
||||
|
||||
name: pangolin
|
||||
services:
|
||||
pangolin:
|
||||
image: fosrl/pangolin:latest
|
||||
@@ -32,12 +31,11 @@ services:
|
||||
- SYS_MODULE
|
||||
ports:
|
||||
- 51820:51820/udp
|
||||
- 8080:8080 # Port for traefik because of the network_mode
|
||||
- 443:443 # Port for traefik because of the network_mode
|
||||
- 80:80 # Port for traefik because of the network_mode
|
||||
|
||||
traefik:
|
||||
image: traefik:v3.1
|
||||
image: traefik:v3.3.3
|
||||
container_name: traefik
|
||||
restart: unless-stopped
|
||||
network_mode: service:gerbil # Ports appear on the gerbil service
|
||||
@@ -47,5 +45,10 @@ services:
|
||||
command:
|
||||
- --configFile=/etc/traefik/traefik_config.yml
|
||||
volumes:
|
||||
- ./traefik:/etc/traefik:ro # Volume to store the Traefik configuration
|
||||
- ./letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
||||
- ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
|
||||
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
||||
|
||||
networks:
|
||||
default:
|
||||
driver: bridge
|
||||
name: pangolin
|
||||
9
eslint.config.js
Normal file
9
eslint.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// eslint.config.js
|
||||
export default [
|
||||
{
|
||||
rules: {
|
||||
semi: "error",
|
||||
"prefer-const": "error"
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -1,13 +1,24 @@
|
||||
all: build
|
||||
all: update-versions go-build-release put-back
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 go build -o bin/installer
|
||||
|
||||
release:
|
||||
go-build-release:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/installer_linux_amd64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64
|
||||
|
||||
clean:
|
||||
rm -f bin/installer
|
||||
rm -f bin/installer_linux_amd64
|
||||
rm -f bin/installer_linux_arm64
|
||||
|
||||
update-versions:
|
||||
@echo "Fetching latest versions..."
|
||||
cp main.go main.go.bak && \
|
||||
PANGOLIN_VERSION=$$(curl -s https://api.github.com/repos/fosrl/pangolin/tags | jq -r '.[0].name') && \
|
||||
GERBIL_VERSION=$$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') && \
|
||||
BADGER_VERSION=$$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') && \
|
||||
echo "Latest versions - Pangolin: $$PANGOLIN_VERSION, Gerbil: $$GERBIL_VERSION, Badger: $$BADGER_VERSION" && \
|
||||
sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$$PANGOLIN_VERSION\"/" main.go && \
|
||||
sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$$GERBIL_VERSION\"/" main.go && \
|
||||
sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$$BADGER_VERSION\"/" main.go && \
|
||||
echo "Updated main.go with latest versions"
|
||||
|
||||
put-back:
|
||||
mv main.go.bak main.go
|
||||
353
install/config.go
Normal file
353
install/config.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// TraefikConfig represents the structure of the main Traefik configuration
|
||||
type TraefikConfig struct {
|
||||
Experimental struct {
|
||||
Plugins struct {
|
||||
Badger struct {
|
||||
Version string `yaml:"version"`
|
||||
} `yaml:"badger"`
|
||||
} `yaml:"plugins"`
|
||||
} `yaml:"experimental"`
|
||||
CertificatesResolvers struct {
|
||||
LetsEncrypt struct {
|
||||
Acme struct {
|
||||
Email string `yaml:"email"`
|
||||
} `yaml:"acme"`
|
||||
} `yaml:"letsencrypt"`
|
||||
} `yaml:"certificatesResolvers"`
|
||||
}
|
||||
|
||||
// DynamicConfig represents the structure of the dynamic configuration
|
||||
type DynamicConfig struct {
|
||||
HTTP struct {
|
||||
Routers map[string]struct {
|
||||
Rule string `yaml:"rule"`
|
||||
} `yaml:"routers"`
|
||||
} `yaml:"http"`
|
||||
}
|
||||
|
||||
// ConfigValues holds the extracted configuration values
|
||||
type ConfigValues struct {
|
||||
DashboardDomain string
|
||||
LetsEncryptEmail string
|
||||
BadgerVersion string
|
||||
}
|
||||
|
||||
// ReadTraefikConfig reads and extracts values from Traefik configuration files
|
||||
func ReadTraefikConfig(mainConfigPath, dynamicConfigPath string) (*ConfigValues, error) {
|
||||
// Read main config file
|
||||
mainConfigData, err := os.ReadFile(mainConfigPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading main config file: %w", err)
|
||||
}
|
||||
|
||||
var mainConfig TraefikConfig
|
||||
if err := yaml.Unmarshal(mainConfigData, &mainConfig); err != nil {
|
||||
return nil, fmt.Errorf("error parsing main config file: %w", err)
|
||||
}
|
||||
|
||||
// Read dynamic config file
|
||||
dynamicConfigData, err := os.ReadFile(dynamicConfigPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading dynamic config file: %w", err)
|
||||
}
|
||||
|
||||
var dynamicConfig DynamicConfig
|
||||
if err := yaml.Unmarshal(dynamicConfigData, &dynamicConfig); err != nil {
|
||||
return nil, fmt.Errorf("error parsing dynamic config file: %w", err)
|
||||
}
|
||||
|
||||
// Extract values
|
||||
values := &ConfigValues{
|
||||
BadgerVersion: mainConfig.Experimental.Plugins.Badger.Version,
|
||||
LetsEncryptEmail: mainConfig.CertificatesResolvers.LetsEncrypt.Acme.Email,
|
||||
}
|
||||
|
||||
// Extract DashboardDomain from router rules
|
||||
// Look for it in the main router rules
|
||||
for _, router := range dynamicConfig.HTTP.Routers {
|
||||
if router.Rule != "" {
|
||||
// Extract domain from Host(`mydomain.com`)
|
||||
if domain := extractDomainFromRule(router.Rule); domain != "" {
|
||||
values.DashboardDomain = domain
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// extractDomainFromRule extracts the domain from a router rule
|
||||
func extractDomainFromRule(rule string) string {
|
||||
// Look for the Host(`mydomain.com`) pattern
|
||||
if start := findPattern(rule, "Host(`"); start != -1 {
|
||||
end := findPattern(rule[start:], "`)")
|
||||
if end != -1 {
|
||||
return rule[start+6 : start+end]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findPattern finds the start of a pattern in a string
|
||||
func findPattern(s, pattern string) int {
|
||||
return bytes.Index([]byte(s), []byte(pattern))
|
||||
}
|
||||
|
||||
func copyDockerService(sourceFile, destFile, serviceName string) error {
|
||||
// Read source file
|
||||
sourceData, err := os.ReadFile(sourceFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading source file: %w", err)
|
||||
}
|
||||
|
||||
// Read destination file
|
||||
destData, err := os.ReadFile(destFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading destination file: %w", err)
|
||||
}
|
||||
|
||||
// Parse source Docker Compose YAML
|
||||
var sourceCompose map[string]interface{}
|
||||
if err := yaml.Unmarshal(sourceData, &sourceCompose); err != nil {
|
||||
return fmt.Errorf("error parsing source Docker Compose file: %w", err)
|
||||
}
|
||||
|
||||
// Parse destination Docker Compose YAML
|
||||
var destCompose map[string]interface{}
|
||||
if err := yaml.Unmarshal(destData, &destCompose); err != nil {
|
||||
return fmt.Errorf("error parsing destination Docker Compose file: %w", err)
|
||||
}
|
||||
|
||||
// Get services section from source
|
||||
sourceServices, ok := sourceCompose["services"].(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("services section not found in source file or has invalid format")
|
||||
}
|
||||
|
||||
// Get the specific service configuration
|
||||
serviceConfig, ok := sourceServices[serviceName]
|
||||
if !ok {
|
||||
return fmt.Errorf("service '%s' not found in source file", serviceName)
|
||||
}
|
||||
|
||||
// Get or create services section in destination
|
||||
destServices, ok := destCompose["services"].(map[string]interface{})
|
||||
if !ok {
|
||||
// If services section doesn't exist, create it
|
||||
destServices = make(map[string]interface{})
|
||||
destCompose["services"] = destServices
|
||||
}
|
||||
|
||||
// Update service in destination
|
||||
destServices[serviceName] = serviceConfig
|
||||
|
||||
// Marshal updated destination YAML
|
||||
// Use yaml.v3 encoder to preserve formatting and comments
|
||||
// updatedData, err := yaml.Marshal(destCompose)
|
||||
updatedData, err := MarshalYAMLWithIndent(destCompose, 2)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshaling updated Docker Compose file: %w", err)
|
||||
}
|
||||
|
||||
// Write updated YAML back to destination file
|
||||
if err := os.WriteFile(destFile, updatedData, 0644); err != nil {
|
||||
return fmt.Errorf("error writing to destination file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func backupConfig() error {
|
||||
// Backup docker-compose.yml
|
||||
if _, err := os.Stat("docker-compose.yml"); err == nil {
|
||||
if err := copyFile("docker-compose.yml", "docker-compose.yml.backup"); err != nil {
|
||||
return fmt.Errorf("failed to backup docker-compose.yml: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Backup config directory
|
||||
if _, err := os.Stat("config"); err == nil {
|
||||
cmd := exec.Command("tar", "-czvf", "config.tar.gz", "config")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to backup config directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func MarshalYAMLWithIndent(data interface{}, indent int) ([]byte, error) {
|
||||
buffer := new(bytes.Buffer)
|
||||
encoder := yaml.NewEncoder(buffer)
|
||||
encoder.SetIndent(indent)
|
||||
|
||||
err := encoder.Encode(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer encoder.Close()
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
func replaceInFile(filepath, oldStr, newStr string) error {
|
||||
// Read the file content
|
||||
content, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading file: %v", err)
|
||||
}
|
||||
|
||||
// Replace the string
|
||||
newContent := strings.Replace(string(content), oldStr, newStr, -1)
|
||||
|
||||
// Write the modified content back to the file
|
||||
err = os.WriteFile(filepath, []byte(newContent), 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error writing file: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CheckAndAddTraefikLogVolume(composePath string) error {
|
||||
// Read the docker-compose.yml file
|
||||
data, err := os.ReadFile(composePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading compose file: %w", err)
|
||||
}
|
||||
|
||||
// Parse YAML into a generic map
|
||||
var compose map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &compose); err != nil {
|
||||
return fmt.Errorf("error parsing compose file: %w", err)
|
||||
}
|
||||
|
||||
// Get services section
|
||||
services, ok := compose["services"].(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("services section not found or invalid")
|
||||
}
|
||||
|
||||
// Get traefik service
|
||||
traefik, ok := services["traefik"].(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("traefik service not found or invalid")
|
||||
}
|
||||
|
||||
// Check volumes
|
||||
logVolume := "./config/traefik/logs:/var/log/traefik"
|
||||
var volumes []interface{}
|
||||
|
||||
if existingVolumes, ok := traefik["volumes"].([]interface{}); ok {
|
||||
// Check if volume already exists
|
||||
for _, v := range existingVolumes {
|
||||
if v.(string) == logVolume {
|
||||
fmt.Println("Traefik log volume is already configured")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
volumes = existingVolumes
|
||||
}
|
||||
|
||||
// Add new volume
|
||||
volumes = append(volumes, logVolume)
|
||||
traefik["volumes"] = volumes
|
||||
|
||||
// Write updated config back to file
|
||||
newData, err := MarshalYAMLWithIndent(compose, 2)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshaling updated compose file: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(composePath, newData, 0644); err != nil {
|
||||
return fmt.Errorf("error writing updated compose file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Added traefik log volume and created logs directory")
|
||||
return nil
|
||||
}
|
||||
|
||||
// MergeYAML merges two YAML files, where the contents of the second file
|
||||
// are merged into the first file. In case of conflicts, values from the
|
||||
// second file take precedence.
|
||||
func MergeYAML(baseFile, overlayFile string) error {
|
||||
// Read the base YAML file
|
||||
baseContent, err := os.ReadFile(baseFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading base file: %v", err)
|
||||
}
|
||||
|
||||
// Read the overlay YAML file
|
||||
overlayContent, err := os.ReadFile(overlayFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading overlay file: %v", err)
|
||||
}
|
||||
|
||||
// Parse base YAML into a map
|
||||
var baseMap map[string]interface{}
|
||||
if err := yaml.Unmarshal(baseContent, &baseMap); err != nil {
|
||||
return fmt.Errorf("error parsing base YAML: %v", err)
|
||||
}
|
||||
|
||||
// Parse overlay YAML into a map
|
||||
var overlayMap map[string]interface{}
|
||||
if err := yaml.Unmarshal(overlayContent, &overlayMap); err != nil {
|
||||
return fmt.Errorf("error parsing overlay YAML: %v", err)
|
||||
}
|
||||
|
||||
// Merge the overlay into the base
|
||||
merged := mergeMap(baseMap, overlayMap)
|
||||
|
||||
// Marshal the merged result back to YAML
|
||||
mergedContent, err := MarshalYAMLWithIndent(merged, 2)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshaling merged YAML: %v", err)
|
||||
}
|
||||
|
||||
// Write the merged content back to the base file
|
||||
if err := os.WriteFile(baseFile, mergedContent, 0644); err != nil {
|
||||
return fmt.Errorf("error writing merged YAML: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeMap recursively merges two maps
|
||||
func mergeMap(base, overlay map[string]interface{}) map[string]interface{} {
|
||||
result := make(map[string]interface{})
|
||||
|
||||
// Copy all key-values from base map
|
||||
for k, v := range base {
|
||||
result[k] = v
|
||||
}
|
||||
|
||||
// Merge overlay values
|
||||
for k, v := range overlay {
|
||||
// If both maps have the same key and both values are maps, merge recursively
|
||||
if baseVal, ok := base[k]; ok {
|
||||
if baseMap, isBaseMap := baseVal.(map[string]interface{}); isBaseMap {
|
||||
if overlayMap, isOverlayMap := v.(map[string]interface{}); isOverlayMap {
|
||||
result[k] = mergeMap(baseMap, overlayMap)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
// Otherwise, overlay value takes precedence
|
||||
result[k] = v
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
app:
|
||||
dashboard_url: "https://{{.DashboardDomain}}"
|
||||
base_domain: "{{.BaseDomain}}"
|
||||
log_level: "info"
|
||||
save_logs: false
|
||||
|
||||
domains:
|
||||
domain1:
|
||||
base_domain: "{{.BaseDomain}}"
|
||||
cert_resolver: "letsencrypt"
|
||||
|
||||
server:
|
||||
external_port: 3000
|
||||
internal_port: 3001
|
||||
@@ -54,3 +58,4 @@ flags:
|
||||
disable_signup_without_invite: {{.DisableSignupWithoutInvite}}
|
||||
disable_user_create_org: {{.DisableUserCreateOrg}}
|
||||
allow_raw_resources: true
|
||||
allow_base_domain_resources: true
|
||||
18
install/config/crowdsec/acquis.yaml
Normal file
18
install/config/crowdsec/acquis.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
filenames:
|
||||
- /var/log/auth.log
|
||||
- /var/log/syslog
|
||||
labels:
|
||||
type: syslog
|
||||
---
|
||||
poll_without_inotify: false
|
||||
filenames:
|
||||
- /var/log/traefik/*.log
|
||||
labels:
|
||||
type: traefik
|
||||
---
|
||||
listen_addr: 0.0.0.0:7422
|
||||
appsec_config: crowdsecurity/appsec-default
|
||||
name: myAppSecComponent
|
||||
source: appsec
|
||||
labels:
|
||||
type: appsec
|
||||
35
install/config/crowdsec/docker-compose.yml
Normal file
35
install/config/crowdsec/docker-compose.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
services:
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:latest
|
||||
container_name: crowdsec
|
||||
environment:
|
||||
GID: "1000"
|
||||
COLLECTIONS: crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
|
||||
ENROLL_INSTANCE_NAME: "pangolin-crowdsec"
|
||||
PARSERS: crowdsecurity/whitelists
|
||||
ACQUIRE_FILES: "/var/log/traefik/*.log"
|
||||
ENROLL_TAGS: docker
|
||||
healthcheck:
|
||||
test: ["CMD", "cscli", "capi", "status"]
|
||||
depends_on:
|
||||
- gerbil # Wait for gerbil to be healthy
|
||||
labels:
|
||||
- "traefik.enable=false" # Disable traefik for crowdsec
|
||||
volumes:
|
||||
# crowdsec container data
|
||||
- ./config/crowdsec:/etc/crowdsec # crowdsec config
|
||||
- ./config/crowdsec/db:/var/lib/crowdsec/data # crowdsec db
|
||||
# log bind mounts into crowdsec
|
||||
- ./config/crowdsec_logs/auth.log:/var/log/auth.log:ro # auth.log
|
||||
- ./config/crowdsec_logs/syslog:/var/log/syslog:ro # syslog
|
||||
- ./config/crowdsec_logs:/var/log # crowdsec logs
|
||||
- ./config/traefik/logs:/var/log/traefik # traefik logs
|
||||
ports:
|
||||
- 9090:9090 # port mapping for local firewall bouncers
|
||||
- 6060:6060 # metrics endpoint for prometheus
|
||||
expose:
|
||||
- 9090 # http api for bouncers
|
||||
- 6060 # metrics endpoint for prometheus
|
||||
- 7422 # appsec waf endpoint
|
||||
restart: unless-stopped
|
||||
command: -t # Add test config flag to verify configuration
|
||||
108
install/config/crowdsec/dynamic_config.yml
Normal file
108
install/config/crowdsec/dynamic_config.yml
Normal file
@@ -0,0 +1,108 @@
|
||||
http:
|
||||
middlewares:
|
||||
redirect-to-https:
|
||||
redirectScheme:
|
||||
scheme: https
|
||||
default-whitelist: # Whitelist middleware for internal IPs
|
||||
ipWhiteList: # Internal IP addresses
|
||||
sourceRange: # Internal IP addresses
|
||||
- "10.0.0.0/8" # Internal IP addresses
|
||||
- "192.168.0.0/16" # Internal IP addresses
|
||||
- "172.16.0.0/12" # Internal IP addresses
|
||||
# Basic security headers
|
||||
security-headers:
|
||||
headers:
|
||||
customResponseHeaders: # Custom response headers
|
||||
Server: "" # Remove server header
|
||||
X-Powered-By: "" # Remove powered by header
|
||||
X-Forwarded-Proto: "https" # Set forwarded proto to https
|
||||
sslProxyHeaders: # SSL proxy headers
|
||||
X-Forwarded-Proto: "https" # Set forwarded proto to https
|
||||
hostsProxyHeaders: # Hosts proxy headers
|
||||
- "X-Forwarded-Host" # Set forwarded host
|
||||
contentTypeNosniff: true # Prevent MIME sniffing
|
||||
customFrameOptionsValue: "SAMEORIGIN" # Set frame options
|
||||
referrerPolicy: "strict-origin-when-cross-origin" # Set referrer policy
|
||||
forceSTSHeader: true # Force STS header
|
||||
stsIncludeSubdomains: true # Include subdomains
|
||||
stsSeconds: 63072000 # STS seconds
|
||||
stsPreload: true # Preload STS
|
||||
# CrowdSec configuration with proper IP forwarding
|
||||
crowdsec:
|
||||
plugin:
|
||||
crowdsec:
|
||||
enabled: true # Enable CrowdSec plugin
|
||||
logLevel: INFO # Log level
|
||||
updateIntervalSeconds: 15 # Update interval
|
||||
updateMaxFailure: 0 # Update max failure
|
||||
defaultDecisionSeconds: 15 # Default decision seconds
|
||||
httpTimeoutSeconds: 10 # HTTP timeout
|
||||
crowdsecMode: live # CrowdSec mode
|
||||
crowdsecAppsecEnabled: true # Enable AppSec
|
||||
crowdsecAppsecHost: crowdsec:7422 # CrowdSec IP address which you noted down later
|
||||
crowdsecAppsecFailureBlock: true # Block on failure
|
||||
crowdsecAppsecUnreachableBlock: true # Block on unreachable
|
||||
crowdsecLapiKey: "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK" # CrowdSec API key which you noted down later
|
||||
crowdsecLapiHost: crowdsec:8080 # CrowdSec
|
||||
crowdsecLapiScheme: http # CrowdSec API scheme
|
||||
forwardedHeadersTrustedIPs: # Forwarded headers trusted IPs
|
||||
- "0.0.0.0/0" # All IP addresses are trusted for forwarded headers (CHANGE MADE HERE)
|
||||
clientTrustedIPs: # Client trusted IPs (CHANGE MADE HERE)
|
||||
- "10.0.0.0/8" # Internal LAN IP addresses
|
||||
- "172.16.0.0/12" # Internal LAN IP addresses
|
||||
- "192.168.0.0/16" # Internal LAN IP addresses
|
||||
- "100.89.137.0/20" # Internal LAN IP addresses
|
||||
|
||||
routers:
|
||||
# HTTP to HTTPS redirect router
|
||||
main-app-router-redirect:
|
||||
rule: "Host(`{{.DashboardDomain}}`)" # Dynamic Domain Name
|
||||
service: next-service
|
||||
entryPoints:
|
||||
- web
|
||||
middlewares:
|
||||
- redirect-to-https
|
||||
|
||||
# Next.js router (handles everything except API and WebSocket paths)
|
||||
next-router:
|
||||
rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)" # Dynamic Domain Name
|
||||
service: next-service
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- security-headers # Add security headers middleware
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
# API router (handles /api/v1 paths)
|
||||
api-router:
|
||||
rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)" # Dynamic Domain Name
|
||||
service: api-service
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- security-headers # Add security headers middleware
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
# WebSocket router
|
||||
ws-router:
|
||||
rule: "Host(`{{.DashboardDomain}}`)" # Dynamic Domain Name
|
||||
service: api-service
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- security-headers # Add security headers middleware
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
services:
|
||||
next-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://pangolin:3002" # Next.js server
|
||||
|
||||
api-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://pangolin:3000" # API/WebSocket server
|
||||
25
install/config/crowdsec/profiles.yaml
Normal file
25
install/config/crowdsec/profiles.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
name: captcha_remediation
|
||||
filters:
|
||||
- Alert.Remediation == true && Alert.GetScope() == "Ip" && Alert.GetScenario() contains "http"
|
||||
decisions:
|
||||
- type: captcha
|
||||
duration: 4h
|
||||
on_success: break
|
||||
|
||||
---
|
||||
name: default_ip_remediation
|
||||
filters:
|
||||
- Alert.Remediation == true && Alert.GetScope() == "Ip"
|
||||
decisions:
|
||||
- type: ban
|
||||
duration: 4h
|
||||
on_success: break
|
||||
|
||||
---
|
||||
name: default_range_remediation
|
||||
filters:
|
||||
- Alert.Remediation == true && Alert.GetScope() == "Range"
|
||||
decisions:
|
||||
- type: ban
|
||||
duration: 4h
|
||||
on_success: break
|
||||
87
install/config/crowdsec/traefik_config.yml
Normal file
87
install/config/crowdsec/traefik_config.yml
Normal file
@@ -0,0 +1,87 @@
|
||||
api:
|
||||
insecure: true
|
||||
dashboard: true
|
||||
|
||||
providers:
|
||||
http:
|
||||
endpoint: "http://pangolin:3001/api/v1/traefik-config"
|
||||
pollInterval: "5s"
|
||||
file:
|
||||
filename: "/etc/traefik/dynamic_config.yml"
|
||||
|
||||
experimental:
|
||||
plugins:
|
||||
badger:
|
||||
moduleName: "github.com/fosrl/badger"
|
||||
version: "{{.BadgerVersion}}"
|
||||
crowdsec: # CrowdSec plugin configuration added
|
||||
moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
version: "v1.3.5"
|
||||
|
||||
log:
|
||||
level: "INFO"
|
||||
format: "json" # Log format changed to json for better parsing
|
||||
|
||||
accessLog: # We enable access logs as json
|
||||
filePath: "/var/log/traefik/access.log"
|
||||
format: json
|
||||
filters:
|
||||
statusCodes:
|
||||
- "200-299" # Success codes
|
||||
- "400-499" # Client errors
|
||||
- "500-599" # Server errors
|
||||
retryAttempts: true
|
||||
minDuration: "100ms" # Increased to focus on slower requests
|
||||
bufferingSize: 100 # Add buffering for better performance
|
||||
fields:
|
||||
defaultMode: drop # Start with dropping all fields
|
||||
names:
|
||||
ClientAddr: keep # Keep client address for IP tracking
|
||||
ClientHost: keep # Keep client host for IP tracking
|
||||
RequestMethod: keep # Keep request method for tracking
|
||||
RequestPath: keep # Keep request path for tracking
|
||||
RequestProtocol: keep # Keep request protocol for tracking
|
||||
DownstreamStatus: keep # Keep downstream status for tracking
|
||||
DownstreamContentSize: keep # Keep downstream content size for tracking
|
||||
Duration: keep # Keep request duration for tracking
|
||||
ServiceName: keep # Keep service name for tracking
|
||||
StartUTC: keep # Keep start time for tracking
|
||||
TLSVersion: keep # Keep TLS version for tracking
|
||||
TLSCipher: keep # Keep TLS cipher for tracking
|
||||
RetryAttempts: keep # Keep retry attempts for tracking
|
||||
headers:
|
||||
defaultMode: drop # Start with dropping all headers
|
||||
names:
|
||||
User-Agent: keep # Keep user agent for tracking
|
||||
X-Real-Ip: keep # Keep real IP for tracking
|
||||
X-Forwarded-For: keep # Keep forwarded IP for tracking
|
||||
X-Forwarded-Proto: keep # Keep forwarded protocol for tracking
|
||||
Content-Type: keep # Keep content type for tracking
|
||||
Authorization: redact # Redact sensitive information
|
||||
Cookie: redact # Redact sensitive information
|
||||
|
||||
certificatesResolvers:
|
||||
letsencrypt:
|
||||
acme:
|
||||
httpChallenge:
|
||||
entryPoint: web
|
||||
email: "{{.LetsEncryptEmail}}"
|
||||
storage: "/letsencrypt/acme.json"
|
||||
caServer: "https://acme-v02.api.letsencrypt.org/directory"
|
||||
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
websecure:
|
||||
address: ":443"
|
||||
transport:
|
||||
respondingTimeouts:
|
||||
readTimeout: "30m"
|
||||
http:
|
||||
tls:
|
||||
certResolver: "letsencrypt"
|
||||
middlewares:
|
||||
- crowdsec@file
|
||||
|
||||
serversTransport:
|
||||
insecureSkipVerify: true
|
||||
@@ -1,3 +1,4 @@
|
||||
name: pangolin
|
||||
services:
|
||||
pangolin:
|
||||
image: fosrl/pangolin:{{.PangolinVersion}}
|
||||
@@ -10,7 +11,6 @@ services:
|
||||
interval: "3s"
|
||||
timeout: "3s"
|
||||
retries: 5
|
||||
|
||||
{{if .InstallGerbil}}
|
||||
gerbil:
|
||||
image: fosrl/gerbil:{{.GerbilVersion}}
|
||||
@@ -34,15 +34,13 @@ services:
|
||||
- 443:443 # Port for traefik because of the network_mode
|
||||
- 80:80 # Port for traefik because of the network_mode
|
||||
{{end}}
|
||||
|
||||
traefik:
|
||||
image: traefik:v3.1
|
||||
image: traefik:v3.3.3
|
||||
container_name: traefik
|
||||
restart: unless-stopped
|
||||
{{if .InstallGerbil}}
|
||||
network_mode: service:gerbil # Ports appear on the gerbil service
|
||||
{{end}}
|
||||
{{if not .InstallGerbil}}
|
||||
{{end}}{{if not .InstallGerbil}}
|
||||
ports:
|
||||
- 443:443
|
||||
- 80:80
|
||||
@@ -55,3 +53,9 @@ services:
|
||||
volumes:
|
||||
- ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
|
||||
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
||||
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
|
||||
|
||||
networks:
|
||||
default:
|
||||
driver: bridge
|
||||
name: pangolin
|
||||
121
install/crowdsec.go
Normal file
121
install/crowdsec.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func installCrowdsec(config Config) error {
|
||||
|
||||
if err := stopContainers(); err != nil {
|
||||
return fmt.Errorf("failed to stop containers: %v", err)
|
||||
}
|
||||
|
||||
// Run installation steps
|
||||
if err := backupConfig(); err != nil {
|
||||
return fmt.Errorf("backup failed: %v", err)
|
||||
}
|
||||
|
||||
if err := createConfigFiles(config); err != nil {
|
||||
fmt.Printf("Error creating config files: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
os.MkdirAll("config/crowdsec/db", 0755)
|
||||
os.MkdirAll("config/crowdsec_logs/syslog", 0755)
|
||||
os.MkdirAll("config/traefik/logs", 0755)
|
||||
|
||||
if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil {
|
||||
fmt.Printf("Error copying docker service: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := MergeYAML("config/traefik/traefik_config.yml", "config/crowdsec/traefik_config.yml"); err != nil {
|
||||
fmt.Printf("Error copying entry points: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// delete the 2nd file
|
||||
if err := os.Remove("config/crowdsec/traefik_config.yml"); err != nil {
|
||||
fmt.Printf("Error removing file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := MergeYAML("config/traefik/dynamic_config.yml", "config/crowdsec/dynamic_config.yml"); err != nil {
|
||||
fmt.Printf("Error copying entry points: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// delete the 2nd file
|
||||
if err := os.Remove("config/crowdsec/dynamic_config.yml"); err != nil {
|
||||
fmt.Printf("Error removing file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := os.Remove("config/crowdsec/docker-compose.yml"); err != nil {
|
||||
fmt.Printf("Error removing file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := CheckAndAddTraefikLogVolume("docker-compose.yml"); err != nil {
|
||||
fmt.Printf("Error checking and adding Traefik log volume: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := startContainers(); err != nil {
|
||||
return fmt.Errorf("failed to start containers: %v", err)
|
||||
}
|
||||
|
||||
// get API key
|
||||
apiKey, err := GetCrowdSecAPIKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API key: %v", err)
|
||||
}
|
||||
config.TraefikBouncerKey = apiKey
|
||||
|
||||
if err := replaceInFile("config/traefik/dynamic_config.yml", "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK", config.TraefikBouncerKey); err != nil {
|
||||
return fmt.Errorf("failed to replace bouncer key: %v", err)
|
||||
}
|
||||
|
||||
if err := restartContainer("traefik"); err != nil {
|
||||
return fmt.Errorf("failed to restart containers: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkIsCrowdsecInstalledInCompose() bool {
|
||||
// Read docker-compose.yml
|
||||
content, err := os.ReadFile("docker-compose.yml")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for crowdsec service
|
||||
return bytes.Contains(content, []byte("crowdsec:"))
|
||||
}
|
||||
|
||||
func GetCrowdSecAPIKey() (string, error) {
|
||||
// First, ensure the container is running
|
||||
if err := waitForContainer("crowdsec"); err != nil {
|
||||
return "", fmt.Errorf("waiting for container: %w", err)
|
||||
}
|
||||
|
||||
// Execute the command to get the API key
|
||||
cmd := exec.Command("docker", "exec", "crowdsec", "cscli", "bouncers", "add", "traefik-bouncer", "-o", "raw")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("executing command: %w", err)
|
||||
}
|
||||
|
||||
// Trim any whitespace from the output
|
||||
apiKey := strings.TrimSpace(out.String())
|
||||
if apiKey == "" {
|
||||
return "", fmt.Errorf("empty API key returned")
|
||||
}
|
||||
|
||||
return apiKey, nil
|
||||
}
|
||||
@@ -5,4 +5,5 @@ go 1.23.0
|
||||
require (
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/term v0.28.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -2,3 +2,6 @@ golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
12
install/input.txt
Normal file
12
install/input.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
example.com
|
||||
pangolin.example.com
|
||||
admin@example.com
|
||||
yes
|
||||
admin@example.com
|
||||
Password123!
|
||||
Password123!
|
||||
yes
|
||||
no
|
||||
no
|
||||
no
|
||||
yes
|
||||
308
install/main.go
308
install/main.go
@@ -4,13 +4,16 @@ import (
|
||||
"bufio"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"time"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"bytes"
|
||||
"text/template"
|
||||
"unicode"
|
||||
|
||||
@@ -24,7 +27,7 @@ func loadVersions(config *Config) {
|
||||
config.BadgerVersion = "replaceme"
|
||||
}
|
||||
|
||||
//go:embed fs/*
|
||||
//go:embed config/*
|
||||
var configFiles embed.FS
|
||||
|
||||
type Config struct {
|
||||
@@ -45,6 +48,8 @@ type Config struct {
|
||||
EmailSMTPPass string
|
||||
EmailNoReply string
|
||||
InstallGerbil bool
|
||||
TraefikBouncerKey string
|
||||
DoCrowdsecInstall bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -56,9 +61,12 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var config Config
|
||||
config.DoCrowdsecInstall = false
|
||||
|
||||
// check if there is already a config file
|
||||
if _, err := os.Stat("config/config.yml"); err != nil {
|
||||
config := collectUserInput(reader)
|
||||
config = collectUserInput(reader)
|
||||
|
||||
loadVersions(&config)
|
||||
|
||||
@@ -67,18 +75,53 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
moveFile("config/docker-compose.yml", "docker-compose.yml")
|
||||
|
||||
if !isDockerInstalled() && runtime.GOOS == "linux" {
|
||||
if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
|
||||
installDocker()
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Starting installation ===")
|
||||
|
||||
if isDockerInstalled() {
|
||||
if readBool(reader, "Would you like to install and start the containers?", true) {
|
||||
pullAndStartContainers()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Config file already exists... skipping configuration")
|
||||
fmt.Println("Looks like you already installed, so I am going to do the setup...")
|
||||
}
|
||||
|
||||
if isDockerInstalled() {
|
||||
if readBool(reader, "Would you like to install and start the containers?", true) {
|
||||
pullAndStartContainers()
|
||||
if !checkIsCrowdsecInstalledInCompose() {
|
||||
fmt.Println("\n=== Crowdsec Install ===")
|
||||
// check if crowdsec is installed
|
||||
if readBool(reader, "Would you like to install Crowdsec?", true) {
|
||||
|
||||
if config.DashboardDomain == "" {
|
||||
traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml", "config/traefik/dynamic_config.yml")
|
||||
if err != nil {
|
||||
fmt.Printf("Error reading config: %v\n", err)
|
||||
return
|
||||
}
|
||||
config.DashboardDomain = traefikConfig.DashboardDomain
|
||||
config.LetsEncryptEmail = traefikConfig.LetsEncryptEmail
|
||||
config.BadgerVersion = traefikConfig.BadgerVersion
|
||||
|
||||
// print the values and check if they are right
|
||||
fmt.Println("Detected values:")
|
||||
fmt.Printf("Dashboard Domain: %s\n", config.DashboardDomain)
|
||||
fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail)
|
||||
fmt.Printf("Badger Version: %s\n", config.BadgerVersion)
|
||||
|
||||
if !readBool(reader, "Are these values correct?", true) {
|
||||
config = collectUserInput(reader)
|
||||
}
|
||||
}
|
||||
|
||||
config.DoCrowdsecInstall = true
|
||||
installCrowdsec(config)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,22 +142,24 @@ func readString(reader *bufio.Reader, prompt string, defaultValue string) string
|
||||
return input
|
||||
}
|
||||
|
||||
func readPassword(prompt string) string {
|
||||
fmt.Print(prompt + ": ")
|
||||
|
||||
// Read password without echo
|
||||
password, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Println() // Add a newline since ReadPassword doesn't add one
|
||||
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
input := strings.TrimSpace(string(password))
|
||||
if input == "" {
|
||||
return readPassword(prompt)
|
||||
}
|
||||
return input
|
||||
func readPassword(prompt string, reader *bufio.Reader) string {
|
||||
if term.IsTerminal(int(syscall.Stdin)) {
|
||||
fmt.Print(prompt + ": ")
|
||||
// Read password without echo if we're in a terminal
|
||||
password, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Println() // Add a newline since ReadPassword doesn't add one
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
input := strings.TrimSpace(string(password))
|
||||
if input == "" {
|
||||
return readPassword(prompt, reader)
|
||||
}
|
||||
return input
|
||||
} else {
|
||||
// Fallback to reading from stdin if not in a terminal
|
||||
return readString(reader, prompt, "")
|
||||
}
|
||||
}
|
||||
|
||||
func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool {
|
||||
@@ -150,8 +195,8 @@ func collectUserInput(reader *bufio.Reader) Config {
|
||||
fmt.Println("\n=== Admin User Configuration ===")
|
||||
config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain)
|
||||
for {
|
||||
pass1 := readPassword("Create admin user password")
|
||||
pass2 := readPassword("Confirm admin user password")
|
||||
pass1 := readPassword("Create admin user password", reader)
|
||||
pass2 := readPassword("Confirm admin user password", reader)
|
||||
|
||||
if pass1 != pass2 {
|
||||
fmt.Println("Passwords do not match")
|
||||
@@ -261,31 +306,33 @@ func createConfigFiles(config Config) error {
|
||||
os.MkdirAll("config/logs", 0755)
|
||||
|
||||
// Walk through all embedded files
|
||||
err := fs.WalkDir(configFiles, "fs", func(path string, d fs.DirEntry, err error) error {
|
||||
err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip the root fs directory itself
|
||||
if path == "fs" {
|
||||
if path == "config" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get the relative path by removing the "fs/" prefix
|
||||
relPath := strings.TrimPrefix(path, "fs/")
|
||||
if !config.DoCrowdsecInstall && strings.Contains(path, "crowdsec") {
|
||||
return nil
|
||||
}
|
||||
|
||||
if config.DoCrowdsecInstall && !strings.Contains(path, "crowdsec") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// skip .DS_Store
|
||||
if strings.Contains(relPath, ".DS_Store") {
|
||||
if strings.Contains(path, ".DS_Store") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create the full output path under "config/"
|
||||
outPath := filepath.Join("config", relPath)
|
||||
|
||||
if d.IsDir() {
|
||||
// Create directory
|
||||
if err := os.MkdirAll(outPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %v", outPath, err)
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %v", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -303,14 +350,14 @@ func createConfigFiles(config Config) error {
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directory for %s: %v", outPath, err)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directory for %s: %v", path, err)
|
||||
}
|
||||
|
||||
// Create output file
|
||||
outFile, err := os.Create(outPath)
|
||||
outFile, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create %s: %v", outPath, err)
|
||||
return fmt.Errorf("failed to create %s: %v", path, err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
@@ -326,30 +373,10 @@ func createConfigFiles(config Config) error {
|
||||
return fmt.Errorf("error walking config files: %v", err)
|
||||
}
|
||||
|
||||
// get the current directory
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current directory: %v", err)
|
||||
}
|
||||
|
||||
sourcePath := filepath.Join(dir, "config/docker-compose.yml")
|
||||
destPath := filepath.Join(dir, "docker-compose.yml")
|
||||
|
||||
// Check if source file exists
|
||||
if _, err := os.Stat(sourcePath); err != nil {
|
||||
return fmt.Errorf("source docker-compose.yml not found: %v", err)
|
||||
}
|
||||
|
||||
// Try to move the file
|
||||
err = os.Rename(sourcePath, destPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to move docker-compose.yml from %s to %s: %v",
|
||||
sourcePath, destPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
func installDocker() error {
|
||||
// Detect Linux distribution
|
||||
cmd := exec.Command("cat", "/etc/os-release")
|
||||
@@ -490,3 +517,166 @@ func pullAndStartContainers() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// bring containers down
|
||||
func stopContainers() error {
|
||||
fmt.Println("Stopping containers...")
|
||||
|
||||
// Check which docker compose command is available
|
||||
var useNewStyle bool
|
||||
checkCmd := exec.Command("docker", "compose", "version")
|
||||
if err := checkCmd.Run(); err == nil {
|
||||
useNewStyle = true
|
||||
} else {
|
||||
// Check if docker-compose (old style) is available
|
||||
checkCmd = exec.Command("docker-compose", "version")
|
||||
if err := checkCmd.Run(); err != nil {
|
||||
return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to execute docker compose commands
|
||||
executeCommand := func(args ...string) error {
|
||||
var cmd *exec.Cmd
|
||||
if useNewStyle {
|
||||
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||
} else {
|
||||
cmd = exec.Command("docker-compose", args...)
|
||||
}
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
if err := executeCommand("-f", "docker-compose.yml", "down"); err != nil {
|
||||
return fmt.Errorf("failed to stop containers: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// just start containers
|
||||
func startContainers() error {
|
||||
fmt.Println("Starting containers...")
|
||||
|
||||
// Check which docker compose command is available
|
||||
var useNewStyle bool
|
||||
checkCmd := exec.Command("docker", "compose", "version")
|
||||
if err := checkCmd.Run(); err == nil {
|
||||
useNewStyle = true
|
||||
} else {
|
||||
// Check if docker-compose (old style) is available
|
||||
checkCmd = exec.Command("docker-compose", "version")
|
||||
if err := checkCmd.Run(); err != nil {
|
||||
return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to execute docker compose commands
|
||||
executeCommand := func(args ...string) error {
|
||||
var cmd *exec.Cmd
|
||||
if useNewStyle {
|
||||
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||
} else {
|
||||
cmd = exec.Command("docker-compose", args...)
|
||||
}
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
if err := executeCommand("-f", "docker-compose.yml", "up", "-d"); err != nil {
|
||||
return fmt.Errorf("failed to start containers: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func restartContainer(container string) error {
|
||||
fmt.Printf("Restarting %s container...\n", container)
|
||||
|
||||
// Check which docker compose command is available
|
||||
var useNewStyle bool
|
||||
checkCmd := exec.Command("docker", "compose", "version")
|
||||
if err := checkCmd.Run(); err == nil {
|
||||
useNewStyle = true
|
||||
} else {
|
||||
// Check if docker-compose (old style) is available
|
||||
checkCmd = exec.Command("docker-compose", "version")
|
||||
if err := checkCmd.Run(); err != nil {
|
||||
return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to execute docker compose commands
|
||||
executeCommand := func(args ...string) error {
|
||||
var cmd *exec.Cmd
|
||||
if useNewStyle {
|
||||
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||
} else {
|
||||
cmd = exec.Command("docker-compose", args...)
|
||||
}
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
if err := executeCommand("-f", "docker-compose.yml", "restart", container); err != nil {
|
||||
return fmt.Errorf("failed to restart %s container: %v", container, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
source, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
destination, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destination.Close()
|
||||
|
||||
_, err = io.Copy(destination, source)
|
||||
return err
|
||||
}
|
||||
|
||||
func moveFile(src, dst string) error {
|
||||
if err := copyFile(src, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Remove(src)
|
||||
}
|
||||
|
||||
func waitForContainer(containerName string) error {
|
||||
maxAttempts := 30
|
||||
retryInterval := time.Second * 2
|
||||
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
// Check if container is running
|
||||
cmd := exec.Command("docker", "container", "inspect", "-f", "{{.State.Running}}", containerName)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
// If the container doesn't exist or there's another error, wait and retry
|
||||
time.Sleep(retryInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
isRunning := strings.TrimSpace(out.String()) == "true"
|
||||
if isRunning {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Container exists but isn't running yet, wait and retry
|
||||
time.Sleep(retryInterval)
|
||||
}
|
||||
|
||||
return fmt.Errorf("container %s did not start within %v seconds", containerName, maxAttempts*int(retryInterval.Seconds()))
|
||||
}
|
||||
267
internationalization/de.md
Normal file
267
internationalization/de.md
Normal file
@@ -0,0 +1,267 @@
|
||||
## Login site
|
||||
|
||||
| EN | DE | Notes |
|
||||
| --------------------- | ---------------------------------- | ----------- |
|
||||
| Welcome to Pangolin | Willkommen bei Pangolin | |
|
||||
| Log in to get started | Melden Sie sich an, um zu beginnen | |
|
||||
| Email | E-Mail | |
|
||||
| Enter your email | Geben Sie Ihre E-Mail-Adresse ein | placeholder |
|
||||
| Password | Passwort | |
|
||||
| Enter your password | Geben Sie Ihr Passwort ein | placeholder |
|
||||
| Forgot your password? | Passwort vergessen? | |
|
||||
| Log in | Anmelden | |
|
||||
|
||||
# Ogranization site after successful login
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ----------------------------------------- | -------------------------------------------- | ----- |
|
||||
| Welcome to Pangolin | Willkommen bei Pangolin | |
|
||||
| You're a member of {number} organization. | Sie sind Mitglied von {number} Organisation. | |
|
||||
|
||||
## Shared Header, Navbar and Footer
|
||||
##### Header
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ------------------- | ------------------- | ----- |
|
||||
| Documentation | Dokumentation | |
|
||||
| Support | Support | |
|
||||
| Organization {name} | Organisation {name} | |
|
||||
##### Organization selector
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ---------------- | ----------------- | ----- |
|
||||
| Search… | Suchen… | |
|
||||
| Create | Erstellen | |
|
||||
| New Organization | Neue Organisation | |
|
||||
| Organizations | Organisationen | |
|
||||
|
||||
##### Navbar
|
||||
|
||||
| EN | DE | Notes |
|
||||
| --------------- | ----------------- | ----- |
|
||||
| Sites | Websites | |
|
||||
| Resources | Ressourcen | |
|
||||
| User & Roles | Benutzer & Rollen | |
|
||||
| Shareable Links | Teilbare Links | |
|
||||
| General | Allgemein | |
|
||||
##### Footer
|
||||
| EN | DE | |
|
||||
| ------------------------- | --------------------------- | ------------------- |
|
||||
| Page {number} of {number} | Seite {number} von {number} | |
|
||||
| Rows per page | Zeilen pro Seite | |
|
||||
| Pangolin | Pangolin | unten auf der Seite |
|
||||
| Built by Fossorial | Erstellt von Fossorial | unten auf der Seite |
|
||||
| Open Source | Open Source | unten auf der Seite |
|
||||
| Documentation | Dokumentation | unten auf der Seite |
|
||||
| {version} | {version} | unten auf der Seite |
|
||||
|
||||
## Main “Sites”
|
||||
##### “Hero” section
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
|
||||
| Newt (Recommended) | Newt (empfohlen) | |
|
||||
| For the best user experience, use Newt. It uses WireGuard under the hood and allows you to address your private resources by their LAN address on your private network from within the Pangolin dashboard. | Für das beste Benutzererlebnis verwenden Sie Newt. Es nutzt WireGuard im Hintergrund und ermöglicht es Ihnen, auf Ihre privaten Ressourcen über ihre LAN-Adresse in Ihrem privaten Netzwerk direkt aus dem Pangolin-Dashboard zuzugreifen. | |
|
||||
| Runs in Docker | Läuft in Docker | |
|
||||
| Runs in shell on macOS, Linux, and Windows | Läuft in der Shell auf macOS, Linux und Windows | |
|
||||
| Install Newt | Newt installieren | |
|
||||
| Basic WireGuard<br> | Verwenden Sie einen beliebigen WireGuard-Client, um eine Verbindung herzustellen. Sie müssen auf Ihre internen Ressourcen über die Peer-IP-Adresse zugreifen. | |
|
||||
| Compatible with all WireGuard clients<br> | Kompatibel mit allen WireGuard-Clients<br> | |
|
||||
| Manual configuration required | Manuelle Konfiguration erforderlich<br> | |
|
||||
##### Content
|
||||
|
||||
| EN | DE | Notes |
|
||||
| --------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------- |
|
||||
| Manage Sites | Seiten verwalten | |
|
||||
| Allow connectivity to your network through secure tunnels | Ermöglichen Sie die Verbindung zu Ihrem Netzwerk über ein sicheren Tunnel | |
|
||||
| Search sites | Seiten suchen | placeholder |
|
||||
| Add Site | Seite hinzufügen | |
|
||||
| Name | Name | table header |
|
||||
| Online | Status | table header |
|
||||
| Site | Seite | table header |
|
||||
| Data In | Eingehende Daten | table header |
|
||||
| Data Out | Ausgehende Daten | table header |
|
||||
| Connection Type | Verbindungstyp | table header |
|
||||
| Online | Online | site state |
|
||||
| Offline | Offline | site state |
|
||||
| Edit → | Bearbeiten → | |
|
||||
| View settings | Einstellungen anzeigen | Popup after clicking “…” on site |
|
||||
| Delete | Löschen | Popup after clicking “…” on site |
|
||||
##### Add Site Popup
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ------------------------------------------------------ | ----------------------------------------------------------- | ----------- |
|
||||
| Create Site | Seite erstellen | |
|
||||
| Create a new site to start connection for this site | Erstellen Sie eine neue Seite, um die Verbindung zu starten | |
|
||||
| Name | Name | |
|
||||
| Site name | Seiten-Name | placeholder |
|
||||
| This is the name that will be displayed for this site. | So wird Ihre Seite angezeigt | desc |
|
||||
| Method | Methode | |
|
||||
| Local | Lokal | |
|
||||
| Newt | Newt | |
|
||||
| WireGuard | WireGuard | |
|
||||
| This is how you will expose connections. | So werden Verbindungen freigegeben. | |
|
||||
| You will only be able to see the configuration once. | Diese Konfiguration können Sie nur einmal sehen. | |
|
||||
| Learn how to install Newt on your system | Erfahren Sie, wie Sie Newt auf Ihrem System installieren | |
|
||||
| I have copied the config | Ich habe die Konfiguration kopiert | |
|
||||
| Create Site | Website erstellen | |
|
||||
| Close | Schließen | |
|
||||
|
||||
## Main “Resources”
|
||||
|
||||
##### “Hero” section
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
|
||||
| Resources | Ressourcen | |
|
||||
| Ressourcen sind Proxy-Server für Anwendungen, die in Ihrem privaten Netzwerk laufen. Erstellen Sie eine Ressource für jede HTTP- oder HTTPS-Anwendung in Ihrem privaten Netzwerk. Jede Ressource muss mit einer Website verbunden sein, um eine private und sichere Verbindung über den verschlüsselten WireGuard-Tunnel zu ermöglichen. | Ressourcen sind Proxy-Server für Anwendungen, die in Ihrem privaten Netzwerk laufen. Erstellen Sie eine Ressource für jede HTTP- oder HTTPS-Anwendung in Ihrem privaten Netzwerk. Jede Ressource muss mit einer Website verbunden sein, um eine private und sichere Verbindung über den verschlüsselten WireGuard-Tunnel zu ermöglichen. | |
|
||||
| Secure connectivity with WireGuard encryption | Sichere Verbindung mit WireGuard-Verschlüsselung | |
|
||||
| Configure multiple authentication methods | Konfigurieren Sie mehrere Authentifizierungsmethoden | |
|
||||
| User and role-based access control | Benutzer- und rollenbasierte Zugriffskontrolle | |
|
||||
##### Content
|
||||
|
||||
| EN | DE | Notes |
|
||||
| -------------------------------------------------- | ---------------------------------------------------------- | -------------------- |
|
||||
| Manage Resources | Ressourcen verwalten | |
|
||||
| Create secure proxies to your private applications | Erstellen Sie sichere Proxys für Ihre privaten Anwendungen | |
|
||||
| Search resources | Ressourcen durchsuchen | placeholder |
|
||||
| Name | Name | |
|
||||
| Site | Website | |
|
||||
| Full URL | Vollständige URL | |
|
||||
| Authentication | Authentifizierung | |
|
||||
| Not Protected | Nicht geschützt | authentication state |
|
||||
| Protected | Geschützt | authentication state |
|
||||
| Edit → | Bearbeiten → | |
|
||||
| Add Resource | Ressource hinzufügen | |
|
||||
##### Add Resource Popup
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------- |
|
||||
| Create Resource | Ressource erstellen | |
|
||||
| Create a new resource to proxy request to your app | Erstellen Sie eine neue Ressource, um Anfragen an Ihre App zu proxen | |
|
||||
| Name | Name | |
|
||||
| My Resource | Neue Ressource | name placeholder |
|
||||
| This is the name that will be displayed for this resource. | Dies ist der Name, der für diese Ressource angezeigt wird | |
|
||||
| Subdomain | Subdomain | |
|
||||
| Enter subdomain | Subdomain eingeben | |
|
||||
| This is the fully qualified domain name that will be used to access the resource. | Dies ist der vollständige Domainname, der für den Zugriff auf die Ressource verwendet wird. | |
|
||||
| Site | Website | |
|
||||
| Search site… | Website suchen… | Site selector popup |
|
||||
| This is the site that will be used in the dashboard. | Dies ist die Website, die im Dashboard verwendet wird. | |
|
||||
| Create Resource | Ressource erstellen | |
|
||||
| Close | Schließen | |
|
||||
|
||||
|
||||
## Main “User & Roles”
|
||||
##### Content
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------- |
|
||||
| Manage User & Roles | Benutzer & Rollen verwalten | |
|
||||
| Invite users and add them to roles to manage access to your organization | Laden Sie Benutzer ein und weisen Sie ihnen Rollen zu, um den Zugriff auf Ihre Organisation zu verwalten | |
|
||||
| Users | Benutzer | sidebar item |
|
||||
| Roles | Rollen | sidebar item |
|
||||
| **User tab** | | |
|
||||
| Search users | Benutzer suchen | placeholder |
|
||||
| Invite User | Benutzer einladen | addbutton |
|
||||
| Email | E-Mail | table header |
|
||||
| Status | Status | table header |
|
||||
| Role | Rolle | table header |
|
||||
| Confirmed | Bestätigt | account status |
|
||||
| Not confirmed (?) | Nicht bestätigt (?) | unknown for me account status |
|
||||
| Owner | Besitzer | role |
|
||||
| Admin | Administrator | role |
|
||||
| Member | Mitglied | role |
|
||||
| **Roles Tab** | | |
|
||||
| Search roles | Rollen suchen | placeholder |
|
||||
| Add Role | Rolle hinzufügen | addbutton |
|
||||
| Name | Name | table header |
|
||||
| Description | Beschreibung | table header |
|
||||
| Admin | Administrator | role |
|
||||
| Member | Mitglied | role |
|
||||
| Admin role with the most permissions | Administratorrolle mit den meisten Berechtigungen | admin role desc |
|
||||
| Members can only view resources | Mitglieder können nur Ressourcen anzeigen | member role desc |
|
||||
|
||||
##### Invite User popup
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ----------------- | ------------------------------------------------------- | ----------- |
|
||||
| Invite User | Geben Sie neuen Benutzern Zugriff auf Ihre Organisation | |
|
||||
| Email | E-Mail | |
|
||||
| Enter an email | E-Mail eingeben | placeholder |
|
||||
| Role | Rolle | |
|
||||
| Select role | Rolle auswählen | placeholder |
|
||||
| Gültig für | Gültig bis | |
|
||||
| 1 day | Tag | |
|
||||
| 2 days | 2 Tage | |
|
||||
| 3 days | 3 Tage | |
|
||||
| 4 days | 4 Tage | |
|
||||
| 5 days | 5 Tage | |
|
||||
| 6 days | 6 Tage | |
|
||||
| 7 days | 7 Tage | |
|
||||
| Create Invitation | Einladung erstellen | |
|
||||
| Close | Schließen | |
|
||||
|
||||
|
||||
## Main “Shareable Links”
|
||||
##### “Hero” section
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
|
||||
| Shareable Links | Teilbare Links | |
|
||||
| Create shareable links to your resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one. | Erstellen Sie teilbare Links zu Ihren Ressourcen. Links bieten temporären oder unbegrenzten Zugriff auf Ihre Ressource. Sie können die Gültigkeitsdauer des Links beim Erstellen konfigurieren. | |
|
||||
| Easy to create and share | Einfach zu erstellen und zu teilen | |
|
||||
| Configurable expiration duration | Konfigurierbare Gültigkeitsdauer | |
|
||||
| Secure and revocable | Sicher und widerrufbar | |
|
||||
##### Content
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------- |
|
||||
| Manage Shareable Links | Teilbare Links verwalten | |
|
||||
| Create shareable links to grant temporary or permanent access to your resources | Erstellen Sie teilbare Links, um temporären oder permanenten Zugriff auf Ihre Ressourcen zu gewähren | |
|
||||
| Search links | Links suchen | placeholder |
|
||||
| Create Share Link | Neuen Link erstellen | addbutton |
|
||||
| Resource | Ressource | table header |
|
||||
| Title | Titel | table header |
|
||||
| Created | Erstellt | table header |
|
||||
| Expires | Gültig bis | table header |
|
||||
| No links. Create one to get started. | Keine Links. Erstellen Sie einen, um zu beginnen. | table placeholder |
|
||||
|
||||
##### Create Shareable Link popup
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------- |
|
||||
| Create Shareable Link | Teilbaren Link erstellen | |
|
||||
| Anyone with this link can access the resource | Jeder mit diesem Link kann auf die Ressource zugreifen | |
|
||||
| Resource | Ressource | |
|
||||
| Select resource | Ressource auswählen | |
|
||||
| Search resources… | Ressourcen suchen… | resource selector popup |
|
||||
| Title (optional) | Titel (optional) | |
|
||||
| Enter title | Titel eingeben | placeholder |
|
||||
| Expire in | Gültig bis | |
|
||||
| Minutes | Minuten | |
|
||||
| Hours | Stunden | |
|
||||
| Days | Tage | |
|
||||
| Months | Monate | |
|
||||
| Years | Jahre | |
|
||||
| Never expire | Nie ablaufen | |
|
||||
| Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource. | Die Gültigkeitsdauer bestimmt, wie lange der Link nutzbar ist und Zugriff auf die Ressource bietet. Nach Ablauf dieser Zeit funktioniert der Link nicht mehr, und Benutzer, die diesen Link verwendet haben, verlieren den Zugriff auf die Ressource. | |
|
||||
| Create Link | Link erstellen | |
|
||||
| Close | Schließen | |
|
||||
|
||||
|
||||
## Main “General”
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------ |
|
||||
| General | Allgemein | |
|
||||
| Configure your organization’s general settings | Konfigurieren Sie die allgemeinen Einstellungen Ihrer Organisation | |
|
||||
| General | Allgemein | sidebar item |
|
||||
| Organization Settings | Organisationseinstellungen | |
|
||||
| Manage your organization details and configuration | Verwalten Sie die Details und Konfiguration Ihrer Organisation | |
|
||||
| Name | Name | |
|
||||
| This is the display name of the org | Dies ist der Anzeigename Ihrer Organisation | |
|
||||
| Save Settings | Einstellungen speichern | |
|
||||
| Danger Zone | Gefahrenzone | |
|
||||
| Once you delete this org, there is no going back. Please be certain. | Wenn Sie diese Organisation löschen, gibt es kein Zurück. Bitte seien Sie sicher. | |
|
||||
| Delete Organization Data | Organisationsdaten löschen | |
|
||||
@@ -1,3 +1,23 @@
|
||||
## Authentication Site
|
||||
|
||||
|
||||
| EN | PL | Notes |
|
||||
| -------------------------------------------------------- | ------------------------------------------------------------ | ---------- |
|
||||
| Powered by [Pangolin](https://github.com/fosrl/pangolin) | Zasilane przez [Pangolin](https://github.com/fosrl/pangolin) | |
|
||||
| Authentication Required | Wymagane uwierzytelnienie | |
|
||||
| Choose your preferred method to access {resource} | Wybierz preferowaną metodę dostępu do {resource} | |
|
||||
| PIN | PIN | |
|
||||
| User | Zaloguj | |
|
||||
| 6-digit PIN Code | 6-cyfrowy kod PIN | pin login |
|
||||
| Login in with PIN | Zaloguj się PIN’em | pin login |
|
||||
| Email | Email | user login |
|
||||
| Enter your email | Wprowadź swój email | user login |
|
||||
| Password | Hasło | user login |
|
||||
| Enter your password | Wprowadź swoje hasło | user login |
|
||||
| Forgot your password? | Zapomniałeś hasła? | user login |
|
||||
| Log in | Zaloguj | user login |
|
||||
|
||||
|
||||
## Login site
|
||||
|
||||
| EN | PL | Notes |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fosrl/pangolin",
|
||||
"version": "1.0.0-beta.10",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",
|
||||
@@ -50,7 +50,6 @@
|
||||
"cookie-parser": "1.4.7",
|
||||
"cors": "2.8.5",
|
||||
"drizzle-orm": "0.38.3",
|
||||
"emblor": "1.4.7",
|
||||
"eslint": "9.17.0",
|
||||
"eslint-config-next": "15.1.3",
|
||||
"express": "4.21.2",
|
||||
@@ -71,6 +70,7 @@
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-easy-sort": "^1.6.0",
|
||||
"react-hook-form": "7.54.2",
|
||||
"rebuild": "0.1.2",
|
||||
"semver": "7.6.3",
|
||||
|
||||
BIN
public/logo/word_mark.png
Normal file
BIN
public/logo/word_mark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -51,13 +51,18 @@ export enum ActionsEnum {
|
||||
// removeUserAction = "removeUserAction",
|
||||
// removeUserSite = "removeUserSite",
|
||||
getOrgUser = "getOrgUser",
|
||||
"setResourcePassword" = "setResourcePassword",
|
||||
"setResourcePincode" = "setResourcePincode",
|
||||
"setResourceWhitelist" = "setResourceWhitelist",
|
||||
"getResourceWhitelist" = "getResourceWhitelist",
|
||||
"generateAccessToken" = "generateAccessToken",
|
||||
"deleteAcessToken" = "deleteAcessToken",
|
||||
"listAccessTokens" = "listAccessTokens"
|
||||
setResourcePassword = "setResourcePassword",
|
||||
setResourcePincode = "setResourcePincode",
|
||||
setResourceWhitelist = "setResourceWhitelist",
|
||||
getResourceWhitelist = "getResourceWhitelist",
|
||||
generateAccessToken = "generateAccessToken",
|
||||
deleteAcessToken = "deleteAcessToken",
|
||||
listAccessTokens = "listAccessTokens",
|
||||
createResourceRule = "createResourceRule",
|
||||
deleteResourceRule = "deleteResourceRule",
|
||||
listResourceRules = "listResourceRules",
|
||||
updateResourceRule = "updateResourceRule",
|
||||
listOrgDomains = "listOrgDomains",
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
||||
@@ -3,8 +3,8 @@ import z from "zod";
|
||||
export const passwordSchema = z
|
||||
.string()
|
||||
.min(8, { message: "Password must be at least 8 characters long" })
|
||||
.max(64, { message: "Password must be at most 64 characters long" })
|
||||
.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).*$/, {
|
||||
.max(128, { message: "Password must be at most 128 characters long" })
|
||||
.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[~!`@#$%^&*()_\-+={}[\]|\\:;"'<>,.\/?]).*$/, {
|
||||
message: `Your password must meet the following conditions:
|
||||
at least one uppercase English letter,
|
||||
at least one lowercase English letter,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
users
|
||||
} from "@server/db/schema";
|
||||
import db from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import config from "@server/lib/config";
|
||||
import type { RandomReader } from "@oslojs/crypto/random";
|
||||
import { generateRandomString } from "@oslojs/crypto/random";
|
||||
@@ -95,11 +95,36 @@ export async function validateSessionToken(
|
||||
}
|
||||
|
||||
export async function invalidateSession(sessionId: string): Promise<void> {
|
||||
await db.delete(sessions).where(eq(sessions.sessionId, sessionId));
|
||||
try {
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(resourceSessions)
|
||||
.where(eq(resourceSessions.userSessionId, sessionId));
|
||||
await trx.delete(sessions).where(eq(sessions.sessionId, sessionId));
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("Failed to invalidate session", e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function invalidateAllSessions(userId: string): Promise<void> {
|
||||
await db.delete(sessions).where(eq(sessions.userId, userId));
|
||||
try {
|
||||
await db.transaction(async (trx) => {
|
||||
const userSessions = await trx
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.userId, userId));
|
||||
await trx.delete(resourceSessions).where(
|
||||
inArray(
|
||||
resourceSessions.userSessionId,
|
||||
userSessions.map((s) => s.sessionId)
|
||||
)
|
||||
);
|
||||
await trx.delete(sessions).where(eq(sessions.userId, userId));
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("Failed to all invalidate user sessions", e);
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeSessionCookie(
|
||||
|
||||
@@ -170,9 +170,9 @@ export function serializeResourceSessionCookie(
|
||||
isHttp: boolean = false
|
||||
): string {
|
||||
if (!isHttp) {
|
||||
return `${cookieName}_s=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${"." + domain}`;
|
||||
return `${cookieName}_s=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${"." + domain}`;
|
||||
} else {
|
||||
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${"." + domain}`;
|
||||
return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${"." + domain}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,9 +182,9 @@ export function createBlankResourceSessionTokenCookie(
|
||||
isHttp: boolean = false
|
||||
): string {
|
||||
if (!isHttp) {
|
||||
return `${cookieName}_s=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${"." + domain}`;
|
||||
return `${cookieName}_s=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${"." + domain}`;
|
||||
} else {
|
||||
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${"." + domain}`;
|
||||
return `${cookieName}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Domain=${"." + domain}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const domains = sqliteTable("domains", {
|
||||
domainId: text("domainId").primaryKey(),
|
||||
baseDomain: text("baseDomain").notNull(),
|
||||
configManaged: integer("configManaged", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false)
|
||||
});
|
||||
|
||||
export const orgs = sqliteTable("orgs", {
|
||||
orgId: text("orgId").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
domain: text("domain").notNull()
|
||||
name: text("name").notNull()
|
||||
});
|
||||
|
||||
export const orgDomains = sqliteTable("orgDomains", {
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
domainId: text("domainId")
|
||||
.notNull()
|
||||
.references(() => domains.domainId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const sites = sqliteTable("sites", {
|
||||
@@ -43,6 +59,9 @@ export const resources = sqliteTable("resources", {
|
||||
name: text("name").notNull(),
|
||||
subdomain: text("subdomain"),
|
||||
fullDomain: text("fullDomain"),
|
||||
domainId: text("domainId").references(() => domains.domainId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
||||
blockAccess: integer("blockAccess", { mode: "boolean" })
|
||||
.notNull()
|
||||
@@ -52,6 +71,10 @@ export const resources = sqliteTable("resources", {
|
||||
protocol: text("protocol").notNull(),
|
||||
proxyPort: integer("proxyPort"),
|
||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
isBaseDomain: integer("isBaseDomain", { mode: "boolean" }),
|
||||
applyRules: integer("applyRules", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false)
|
||||
});
|
||||
@@ -370,6 +393,18 @@ export const versionMigrations = sqliteTable("versionMigrations", {
|
||||
executedAt: integer("executedAt").notNull()
|
||||
});
|
||||
|
||||
export const resourceRules = sqliteTable("resourceRules", {
|
||||
ruleId: integer("ruleId").primaryKey({ autoIncrement: true }),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
priority: integer("priority").notNull(),
|
||||
action: text("action").notNull(), // ACCEPT, DROP
|
||||
match: text("match").notNull(), // CIDR, PATH, IP
|
||||
value: text("value").notNull()
|
||||
});
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -402,3 +437,5 @@ export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
|
||||
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
|
||||
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
|
||||
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
||||
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
||||
export type Domain = InferSelectModel<typeof domains>;
|
||||
|
||||
@@ -6,10 +6,10 @@ import { fromError } from "zod-validation-error";
|
||||
import {
|
||||
__DIRNAME,
|
||||
APP_PATH,
|
||||
APP_VERSION,
|
||||
configFilePath1,
|
||||
configFilePath2
|
||||
} from "@server/lib/consts";
|
||||
import { loadAppVersion } from "@server/lib/loadAppVersion";
|
||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
import stoi from "./stoi";
|
||||
|
||||
@@ -34,15 +34,49 @@ const configSchema = z.object({
|
||||
.transform(getEnvOrYaml("APP_DASHBOARDURL"))
|
||||
.pipe(z.string().url())
|
||||
.transform((url) => url.toLowerCase()),
|
||||
base_domain: hostnameSchema
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("APP_BASEDOMAIN"))
|
||||
.pipe(hostnameSchema)
|
||||
.transform((url) => url.toLowerCase()),
|
||||
log_level: z.enum(["debug", "info", "warn", "error"]),
|
||||
save_logs: z.boolean(),
|
||||
log_failed_attempts: z.boolean().optional()
|
||||
}),
|
||||
domains: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
base_domain: hostnameSchema.transform((url) =>
|
||||
url.toLowerCase()
|
||||
),
|
||||
cert_resolver: z.string().optional(),
|
||||
prefer_wildcard_cert: z.boolean().optional()
|
||||
})
|
||||
)
|
||||
.refine(
|
||||
(domains) => {
|
||||
const keys = Object.keys(domains);
|
||||
|
||||
if (keys.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "At least one domain must be defined"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(domains) => {
|
||||
const envBaseDomain = process.env.APP_BASE_DOMAIN;
|
||||
|
||||
if (envBaseDomain) {
|
||||
return hostnameSchema.safeParse(envBaseDomain).success;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "APP_BASE_DOMAIN must be a valid hostname"
|
||||
}
|
||||
),
|
||||
server: z.object({
|
||||
external_port: portSchema
|
||||
.optional()
|
||||
@@ -88,8 +122,6 @@ const configSchema = z.object({
|
||||
traefik: z.object({
|
||||
http_entrypoint: z.string(),
|
||||
https_entrypoint: z.string().optional(),
|
||||
cert_resolver: z.string().optional(),
|
||||
prefer_wildcard_cert: z.boolean().optional(),
|
||||
additional_middlewares: z.array(z.string()).optional()
|
||||
}),
|
||||
gerbil: z.object({
|
||||
@@ -151,7 +183,8 @@ const configSchema = z.object({
|
||||
require_email_verification: z.boolean().optional(),
|
||||
disable_signup_without_invite: z.boolean().optional(),
|
||||
disable_user_create_org: z.boolean().optional(),
|
||||
allow_raw_resources: z.boolean().optional()
|
||||
allow_raw_resources: z.boolean().optional(),
|
||||
allow_base_domain_resources: z.boolean().optional()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
@@ -167,8 +200,6 @@ export class Config {
|
||||
}
|
||||
}
|
||||
|
||||
public loadEnvironment() {}
|
||||
|
||||
public loadConfig() {
|
||||
const loadConfig = (configPath: string) => {
|
||||
try {
|
||||
@@ -239,11 +270,7 @@ export class Config {
|
||||
throw new Error(`Invalid configuration file: ${errors}`);
|
||||
}
|
||||
|
||||
const appVersion = loadAppVersion();
|
||||
if (!appVersion) {
|
||||
throw new Error("Could not load the application version");
|
||||
}
|
||||
process.env.APP_VERSION = appVersion;
|
||||
process.env.APP_VERSION = APP_VERSION;
|
||||
|
||||
process.env.NEXT_PORT = parsedConfig.data.server.next_port.toString();
|
||||
process.env.SERVER_EXTERNAL_PORT =
|
||||
@@ -255,9 +282,9 @@ export class Config {
|
||||
? "true"
|
||||
: "false";
|
||||
process.env.FLAGS_ALLOW_RAW_RESOURCES = parsedConfig.data.flags
|
||||
?.allow_raw_resources
|
||||
? "true"
|
||||
: "false";
|
||||
?.allow_raw_resources
|
||||
? "true"
|
||||
: "false";
|
||||
process.env.SESSION_COOKIE_NAME =
|
||||
parsedConfig.data.server.session_cookie_name;
|
||||
process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false";
|
||||
@@ -273,6 +300,22 @@ export class Config {
|
||||
parsedConfig.data.server.resource_access_token_param;
|
||||
process.env.RESOURCE_SESSION_REQUEST_PARAM =
|
||||
parsedConfig.data.server.resource_session_request_param;
|
||||
process.env.FLAGS_ALLOW_BASE_DOMAIN_RESOURCES = parsedConfig.data.flags
|
||||
?.allow_base_domain_resources
|
||||
? "true"
|
||||
: "false";
|
||||
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
|
||||
|
||||
if (process.env.APP_BASE_DOMAIN) {
|
||||
console.log(
|
||||
`DEPRECATED! APP_BASE_DOMAIN is deprecated and will be removed in a future release. Use the domains section in the configuration file instead. See https://docs.fossorial.io/Pangolin/Configuration/config for more information.`
|
||||
);
|
||||
|
||||
parsedConfig.data.domains.domain1 = {
|
||||
base_domain: process.env.APP_BASE_DOMAIN,
|
||||
cert_resolver: "letsencrypt"
|
||||
};
|
||||
}
|
||||
|
||||
this.rawConfig = parsedConfig.data;
|
||||
}
|
||||
@@ -281,16 +324,16 @@ export class Config {
|
||||
return this.rawConfig;
|
||||
}
|
||||
|
||||
public getBaseDomain(): string {
|
||||
return this.rawConfig.app.base_domain;
|
||||
}
|
||||
|
||||
public getNoReplyEmail(): string | undefined {
|
||||
return (
|
||||
this.rawConfig.email?.no_reply || this.rawConfig.email?.smtp_user
|
||||
);
|
||||
}
|
||||
|
||||
public getDomain(domainId: string) {
|
||||
return this.rawConfig.domains[domainId];
|
||||
}
|
||||
|
||||
private createTraefikConfig() {
|
||||
try {
|
||||
// check if traefik_config.yml and dynamic_config.yml exists in APP_PATH/traefik
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
// This is a placeholder value replaced by the build process
|
||||
export const APP_VERSION = "1.0.0-beta.15";
|
||||
|
||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||
export const __DIRNAME = path.dirname(__FILENAME);
|
||||
|
||||
183
server/lib/ip.test.ts
Normal file
183
server/lib/ip.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { cidrToRange, findNextAvailableCidr } from "./ip";
|
||||
|
||||
/**
|
||||
* Compares two objects for deep equality
|
||||
* @param actual The actual value to test
|
||||
* @param expected The expected value to compare against
|
||||
* @param message The message to display if assertion fails
|
||||
* @throws Error if objects are not equal
|
||||
*/
|
||||
export function assertEqualsObj<T>(actual: T, expected: T, message: string): void {
|
||||
const actualStr = JSON.stringify(actual);
|
||||
const expectedStr = JSON.stringify(expected);
|
||||
if (actualStr !== expectedStr) {
|
||||
throw new Error(`${message}\nExpected: ${expectedStr}\nActual: ${actualStr}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two primitive values for equality
|
||||
* @param actual The actual value to test
|
||||
* @param expected The expected value to compare against
|
||||
* @param message The message to display if assertion fails
|
||||
* @throws Error if values are not equal
|
||||
*/
|
||||
export function assertEquals<T>(actual: T, expected: T, message: string): void {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}\nExpected: ${expected}\nActual: ${actual}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests if a function throws an expected error
|
||||
* @param fn The function to test
|
||||
* @param expectedError The expected error message or part of it
|
||||
* @param message The message to display if assertion fails
|
||||
* @throws Error if function doesn't throw or throws unexpected error
|
||||
*/
|
||||
export function assertThrows(
|
||||
fn: () => void,
|
||||
expectedError: string,
|
||||
message: string
|
||||
): void {
|
||||
try {
|
||||
fn();
|
||||
throw new Error(`${message}: Expected to throw "${expectedError}"`);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw new Error(`${message}\nUnexpected error type: ${typeof error}`);
|
||||
}
|
||||
|
||||
if (!error.message.includes(expectedError)) {
|
||||
throw new Error(
|
||||
`${message}\nExpected error: ${expectedError}\nActual error: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Test cases
|
||||
function testFindNextAvailableCidr() {
|
||||
console.log("Running findNextAvailableCidr tests...");
|
||||
|
||||
// Test 1: Basic IPv4 allocation
|
||||
{
|
||||
const existing = ["10.0.0.0/16", "10.1.0.0/16"];
|
||||
const result = findNextAvailableCidr(existing, 16, "10.0.0.0/8");
|
||||
assertEquals(result, "10.2.0.0/16", "Basic IPv4 allocation failed");
|
||||
}
|
||||
|
||||
// Test 2: Finding gap between allocations
|
||||
{
|
||||
const existing = ["10.0.0.0/16", "10.2.0.0/16"];
|
||||
const result = findNextAvailableCidr(existing, 16, "10.0.0.0/8");
|
||||
assertEquals(result, "10.1.0.0/16", "Finding gap between allocations failed");
|
||||
}
|
||||
|
||||
// Test 3: No available space
|
||||
{
|
||||
const existing = ["10.0.0.0/8"];
|
||||
const result = findNextAvailableCidr(existing, 8, "10.0.0.0/8");
|
||||
assertEquals(result, null, "No available space test failed");
|
||||
}
|
||||
|
||||
// // Test 4: IPv6 allocation
|
||||
// {
|
||||
// const existing = ["2001:db8::/32", "2001:db8:1::/32"];
|
||||
// const result = findNextAvailableCidr(existing, 32, "2001:db8::/16");
|
||||
// assertEquals(result, "2001:db8:2::/32", "Basic IPv6 allocation failed");
|
||||
// }
|
||||
|
||||
// // Test 5: Mixed IP versions
|
||||
// {
|
||||
// const existing = ["10.0.0.0/16", "2001:db8::/32"];
|
||||
// assertThrows(
|
||||
// () => findNextAvailableCidr(existing, 16),
|
||||
// "All CIDRs must be of the same IP version",
|
||||
// "Mixed IP versions test failed"
|
||||
// );
|
||||
// }
|
||||
|
||||
// Test 6: Empty input
|
||||
{
|
||||
const existing: string[] = [];
|
||||
const result = findNextAvailableCidr(existing, 16);
|
||||
assertEquals(result, null, "Empty input test failed");
|
||||
}
|
||||
|
||||
// Test 7: Block size alignment
|
||||
{
|
||||
const existing = ["10.0.0.0/24"];
|
||||
const result = findNextAvailableCidr(existing, 24, "10.0.0.0/16");
|
||||
assertEquals(result, "10.0.1.0/24", "Block size alignment test failed");
|
||||
}
|
||||
|
||||
// Test 8: Block size alignment
|
||||
{
|
||||
const existing: string[] = [];
|
||||
const result = findNextAvailableCidr(existing, 24, "10.0.0.0/16");
|
||||
assertEquals(result, "10.0.0.0/24", "Block size alignment test failed");
|
||||
}
|
||||
|
||||
// Test 9: Large block size request
|
||||
{
|
||||
const existing = ["10.0.0.0/24", "10.0.1.0/24"];
|
||||
const result = findNextAvailableCidr(existing, 16, "10.0.0.0/16");
|
||||
assertEquals(result, null, "Large block size request test failed");
|
||||
}
|
||||
|
||||
console.log("All findNextAvailableCidr tests passed!");
|
||||
}
|
||||
|
||||
// function testCidrToRange() {
|
||||
// console.log("Running cidrToRange tests...");
|
||||
|
||||
// // Test 1: Basic IPv4 conversion
|
||||
// {
|
||||
// const result = cidrToRange("192.168.0.0/24");
|
||||
// assertEqualsObj(result, {
|
||||
// start: BigInt("3232235520"),
|
||||
// end: BigInt("3232235775")
|
||||
// }, "Basic IPv4 conversion failed");
|
||||
// }
|
||||
|
||||
// // Test 2: IPv6 conversion
|
||||
// {
|
||||
// const result = cidrToRange("2001:db8::/32");
|
||||
// assertEqualsObj(result, {
|
||||
// start: BigInt("42540766411282592856903984951653826560"),
|
||||
// end: BigInt("42540766411282592875350729025363378175")
|
||||
// }, "IPv6 conversion failed");
|
||||
// }
|
||||
|
||||
// // Test 3: Invalid prefix length
|
||||
// {
|
||||
// assertThrows(
|
||||
// () => cidrToRange("192.168.0.0/33"),
|
||||
// "Invalid prefix length for IPv4",
|
||||
// "Invalid IPv4 prefix test failed"
|
||||
// );
|
||||
// }
|
||||
|
||||
// // Test 4: Invalid IPv6 prefix
|
||||
// {
|
||||
// assertThrows(
|
||||
// () => cidrToRange("2001:db8::/129"),
|
||||
// "Invalid prefix length for IPv6",
|
||||
// "Invalid IPv6 prefix test failed"
|
||||
// );
|
||||
// }
|
||||
|
||||
// console.log("All cidrToRange tests passed!");
|
||||
// }
|
||||
|
||||
// Run all tests
|
||||
try {
|
||||
// testCidrToRange();
|
||||
testFindNextAvailableCidr();
|
||||
console.log("All tests passed successfully!");
|
||||
} catch (error) {
|
||||
console.error("Test failed:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
154
server/lib/ip.ts
154
server/lib/ip.ts
@@ -3,58 +3,162 @@ interface IPRange {
|
||||
end: bigint;
|
||||
}
|
||||
|
||||
type IPVersion = 4 | 6;
|
||||
|
||||
/**
|
||||
* Converts IP address string to BigInt for numerical operations
|
||||
* Detects IP version from address string
|
||||
*/
|
||||
function detectIpVersion(ip: string): IPVersion {
|
||||
return ip.includes(':') ? 6 : 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts IPv4 or IPv6 address string to BigInt for numerical operations
|
||||
*/
|
||||
function ipToBigInt(ip: string): bigint {
|
||||
return ip.split('.')
|
||||
.reduce((acc, octet) => BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(parseInt(octet))), BigInt(0));
|
||||
const version = detectIpVersion(ip);
|
||||
|
||||
if (version === 4) {
|
||||
return ip.split('.')
|
||||
.reduce((acc, octet) => {
|
||||
const num = parseInt(octet);
|
||||
if (isNaN(num) || num < 0 || num > 255) {
|
||||
throw new Error(`Invalid IPv4 octet: ${octet}`);
|
||||
}
|
||||
return BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(num));
|
||||
}, BigInt(0));
|
||||
} else {
|
||||
// Handle IPv6
|
||||
// Expand :: notation
|
||||
let fullAddress = ip;
|
||||
if (ip.includes('::')) {
|
||||
const parts = ip.split('::');
|
||||
if (parts.length > 2) throw new Error('Invalid IPv6 address: multiple :: found');
|
||||
const missing = 8 - (parts[0].split(':').length + parts[1].split(':').length);
|
||||
const padding = Array(missing).fill('0').join(':');
|
||||
fullAddress = `${parts[0]}:${padding}:${parts[1]}`;
|
||||
}
|
||||
|
||||
return fullAddress.split(':')
|
||||
.reduce((acc, hextet) => {
|
||||
const num = parseInt(hextet || '0', 16);
|
||||
if (isNaN(num) || num < 0 || num > 65535) {
|
||||
throw new Error(`Invalid IPv6 hextet: ${hextet}`);
|
||||
}
|
||||
return BigInt.asUintN(128, (acc << BigInt(16)) + BigInt(num));
|
||||
}, BigInt(0));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts BigInt to IP address string
|
||||
*/
|
||||
function bigIntToIp(num: bigint): string {
|
||||
const octets: number[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
octets.unshift(Number(num & BigInt(255)));
|
||||
num = num >> BigInt(8);
|
||||
function bigIntToIp(num: bigint, version: IPVersion): string {
|
||||
if (version === 4) {
|
||||
const octets: number[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
octets.unshift(Number(num & BigInt(255)));
|
||||
num = num >> BigInt(8);
|
||||
}
|
||||
return octets.join('.');
|
||||
} else {
|
||||
const hextets: string[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
hextets.unshift(Number(num & BigInt(65535)).toString(16).padStart(4, '0'));
|
||||
num = num >> BigInt(16);
|
||||
}
|
||||
// Compress zero sequences
|
||||
let maxZeroStart = -1;
|
||||
let maxZeroLength = 0;
|
||||
let currentZeroStart = -1;
|
||||
let currentZeroLength = 0;
|
||||
|
||||
for (let i = 0; i < hextets.length; i++) {
|
||||
if (hextets[i] === '0000') {
|
||||
if (currentZeroStart === -1) currentZeroStart = i;
|
||||
currentZeroLength++;
|
||||
if (currentZeroLength > maxZeroLength) {
|
||||
maxZeroLength = currentZeroLength;
|
||||
maxZeroStart = currentZeroStart;
|
||||
}
|
||||
} else {
|
||||
currentZeroStart = -1;
|
||||
currentZeroLength = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (maxZeroLength > 1) {
|
||||
hextets.splice(maxZeroStart, maxZeroLength, '');
|
||||
if (maxZeroStart === 0) hextets.unshift('');
|
||||
if (maxZeroStart + maxZeroLength === 8) hextets.push('');
|
||||
}
|
||||
|
||||
return hextets.map(h => h === '0000' ? '0' : h.replace(/^0+/, '')).join(':');
|
||||
}
|
||||
return octets.join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts CIDR to IP range
|
||||
*/
|
||||
function cidrToRange(cidr: string): IPRange {
|
||||
export function cidrToRange(cidr: string): IPRange {
|
||||
const [ip, prefix] = cidr.split('/');
|
||||
const version = detectIpVersion(ip);
|
||||
const prefixBits = parseInt(prefix);
|
||||
const ipBigInt = ipToBigInt(ip);
|
||||
const mask = BigInt.asUintN(64, (BigInt(1) << BigInt(32 - prefixBits)) - BigInt(1));
|
||||
|
||||
// Validate prefix length
|
||||
const maxPrefix = version === 4 ? 32 : 128;
|
||||
if (prefixBits < 0 || prefixBits > maxPrefix) {
|
||||
throw new Error(`Invalid prefix length for IPv${version}: ${prefix}`);
|
||||
}
|
||||
|
||||
const shiftBits = BigInt(maxPrefix - prefixBits);
|
||||
const mask = BigInt.asUintN(version === 4 ? 64 : 128, (BigInt(1) << shiftBits) - BigInt(1));
|
||||
const start = ipBigInt & ~mask;
|
||||
const end = start | mask;
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the next available CIDR block given existing allocations
|
||||
* @param existingCidrs Array of existing CIDR blocks
|
||||
* @param blockSize Desired prefix length for the new block (e.g., 24 for /24)
|
||||
* @param startCidr Optional CIDR to start searching from (default: "0.0.0.0/0")
|
||||
* @param blockSize Desired prefix length for the new block
|
||||
* @param startCidr Optional CIDR to start searching from
|
||||
* @returns Next available CIDR block or null if none found
|
||||
*/
|
||||
export function findNextAvailableCidr(
|
||||
existingCidrs: string[],
|
||||
blockSize: number,
|
||||
startCidr: string = "0.0.0.0/0"
|
||||
startCidr?: string
|
||||
): string | null {
|
||||
|
||||
if (!startCidr && existingCidrs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If no existing CIDRs, use the IP version from startCidr
|
||||
const version = startCidr
|
||||
? detectIpVersion(startCidr.split('/')[0])
|
||||
: 4; // Default to IPv4 if no startCidr provided
|
||||
|
||||
// Use appropriate default startCidr if none provided
|
||||
startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0");
|
||||
|
||||
// If there are existing CIDRs, ensure all are same version
|
||||
if (existingCidrs.length > 0 &&
|
||||
existingCidrs.some(cidr => detectIpVersion(cidr.split('/')[0]) !== version)) {
|
||||
throw new Error('All CIDRs must be of the same IP version');
|
||||
}
|
||||
|
||||
// Convert existing CIDRs to ranges and sort them
|
||||
const existingRanges = existingCidrs
|
||||
.map(cidr => cidrToRange(cidr))
|
||||
.sort((a, b) => (a.start < b.start ? -1 : 1));
|
||||
|
||||
// Calculate block size
|
||||
const blockSizeBigInt = BigInt(1) << BigInt(32 - blockSize);
|
||||
const maxPrefix = version === 4 ? 32 : 128;
|
||||
const blockSizeBigInt = BigInt(1) << BigInt(maxPrefix - blockSize);
|
||||
|
||||
// Start from the beginning of the given CIDR
|
||||
let current = cidrToRange(startCidr).start;
|
||||
@@ -63,7 +167,6 @@ export function findNextAvailableCidr(
|
||||
// Iterate through existing ranges
|
||||
for (let i = 0; i <= existingRanges.length; i++) {
|
||||
const nextRange = existingRanges[i];
|
||||
|
||||
// Align current to block size
|
||||
const alignedCurrent = current + ((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt);
|
||||
|
||||
@@ -74,7 +177,7 @@ export function findNextAvailableCidr(
|
||||
|
||||
// If we're at the end of existing ranges or found a gap
|
||||
if (!nextRange || alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start) {
|
||||
return `${bigIntToIp(alignedCurrent)}/${blockSize}`;
|
||||
return `${bigIntToIp(alignedCurrent, version)}/${blockSize}`;
|
||||
}
|
||||
|
||||
// Move current pointer to after the current range
|
||||
@@ -85,12 +188,19 @@ export function findNextAvailableCidr(
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given IP address is within a CIDR range
|
||||
* @param ip IP address to check
|
||||
* @param cidr CIDR range to check against
|
||||
* @returns boolean indicating if IP is within the CIDR range
|
||||
*/
|
||||
* Checks if a given IP address is within a CIDR range
|
||||
* @param ip IP address to check
|
||||
* @param cidr CIDR range to check against
|
||||
* @returns boolean indicating if IP is within the CIDR range
|
||||
*/
|
||||
export function isIpInCidr(ip: string, cidr: string): boolean {
|
||||
const ipVersion = detectIpVersion(ip);
|
||||
const cidrVersion = detectIpVersion(cidr.split('/')[0]);
|
||||
|
||||
if (ipVersion !== cidrVersion) {
|
||||
throw new Error('IP address and CIDR must be of the same version');
|
||||
}
|
||||
|
||||
const ipBigInt = ipToBigInt(ip);
|
||||
const range = cidrToRange(cidr);
|
||||
return ipBigInt >= range.start && ipBigInt <= range.end;
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import path from "path";
|
||||
import { __DIRNAME } from "@server/lib/consts";
|
||||
import fs from "fs";
|
||||
|
||||
export function loadAppVersion() {
|
||||
const packageJsonPath = path.join("package.json");
|
||||
let packageJson: any;
|
||||
if (fs.existsSync && fs.existsSync(packageJsonPath)) {
|
||||
const packageJsonContent = fs.readFileSync(packageJsonPath, "utf8");
|
||||
packageJson = JSON.parse(packageJsonContent);
|
||||
|
||||
if (packageJson.version) {
|
||||
return packageJson.version;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,4 @@ export const subdomainSchema = z
|
||||
)
|
||||
.min(1, "Subdomain must be at least 1 character long")
|
||||
.transform((val) => val.toLowerCase());
|
||||
|
||||
96
server/lib/validators.ts
Normal file
96
server/lib/validators.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import z from "zod";
|
||||
|
||||
export function isValidCIDR(cidr: string): boolean {
|
||||
return z.string().cidr().safeParse(cidr).success;
|
||||
}
|
||||
|
||||
export function isValidIP(ip: string): boolean {
|
||||
return z.string().ip().safeParse(ip).success;
|
||||
}
|
||||
|
||||
export function isValidUrlGlobPattern(pattern: string): boolean {
|
||||
// Remove leading slash if present
|
||||
pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern;
|
||||
|
||||
// Empty string is not valid
|
||||
if (!pattern) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Split path into segments
|
||||
const segments = pattern.split("/");
|
||||
|
||||
// Check each segment
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i];
|
||||
|
||||
// Empty segments are not allowed (double slashes), except at the end
|
||||
if (!segment && i !== segments.length - 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If segment contains *, it must be exactly *
|
||||
if (segment.includes("*") && segment !== "*") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check each character in the segment
|
||||
for (let j = 0; j < segment.length; j++) {
|
||||
const char = segment[j];
|
||||
|
||||
// Check for percent-encoded sequences
|
||||
if (char === "%" && j + 2 < segment.length) {
|
||||
const hex1 = segment[j + 1];
|
||||
const hex2 = segment[j + 2];
|
||||
if (
|
||||
!/^[0-9A-Fa-f]$/.test(hex1) ||
|
||||
!/^[0-9A-Fa-f]$/.test(hex2)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
j += 2; // Skip the next two characters
|
||||
continue;
|
||||
}
|
||||
|
||||
// Allow:
|
||||
// - unreserved (A-Z a-z 0-9 - . _ ~)
|
||||
// - sub-delims (! $ & ' ( ) * + , ; =)
|
||||
// - @ : for compatibility with some systems
|
||||
if (!/^[A-Za-z0-9\-._~!$&'()*+,;=@:]$/.test(char)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isUrlValid(url: string | undefined) {
|
||||
if (!url) return true; // the link is optional in the schema so if it's empty it's valid
|
||||
var pattern = new RegExp(
|
||||
"^(https?:\\/\\/)?" + // protocol
|
||||
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name
|
||||
"((\\d{1,3}\\.){3}\\d{1,3}))" + // OR ip (v4) address
|
||||
"(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + // port and path
|
||||
"(\\?[;&a-z\\d%_.~+=-]*)?" + // query string
|
||||
"(\\#[-a-z\\d_]*)?$",
|
||||
"i"
|
||||
);
|
||||
return !!pattern.test(url);
|
||||
}
|
||||
|
||||
export function isTargetValid(value: string | undefined) {
|
||||
if (!value) return true;
|
||||
|
||||
const DOMAIN_REGEX =
|
||||
/^[a-zA-Z0-9_](?:[a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?(?:\.[a-zA-Z0-9_](?:[a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?)*$/;
|
||||
const IPV4_REGEX =
|
||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
|
||||
|
||||
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return DOMAIN_REGEX.test(value);
|
||||
}
|
||||
@@ -31,7 +31,12 @@ export async function logout(
|
||||
}
|
||||
|
||||
try {
|
||||
await invalidateSession(session.sessionId);
|
||||
try {
|
||||
await invalidateSession(session.sessionId);
|
||||
} catch (error) {
|
||||
logger.error("Failed to invalidate session", error)
|
||||
}
|
||||
|
||||
const isSecure = req.protocol === "https";
|
||||
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
|
||||
|
||||
|
||||
@@ -8,10 +8,8 @@ import { db } from "@server/db";
|
||||
import { passwordResetTokens, users } from "@server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
|
||||
import { encodeHex } from "oslo/encoding";
|
||||
import { createDate } from "oslo";
|
||||
import logger from "@server/logger";
|
||||
import { generateIdFromEntropySize } from "@server/auth/sessions/app";
|
||||
import { TimeSpan } from "oslo";
|
||||
import config from "@server/lib/config";
|
||||
import { sendEmail } from "@server/emails";
|
||||
@@ -85,7 +83,9 @@ export async function requestPasswordReset(
|
||||
const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${email}&token=${token}`;
|
||||
|
||||
if (!config.getRawConfig().email) {
|
||||
logger.info(`Password reset requested for ${email}. Token: ${token}.`);
|
||||
logger.info(
|
||||
`Password reset requested for ${email}. Token: ${token}.`
|
||||
);
|
||||
}
|
||||
|
||||
await sendEmail(
|
||||
|
||||
@@ -149,8 +149,6 @@ export async function resetPassword(
|
||||
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
|
||||
await invalidateAllSessions(resetRequest[0].userId);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.update(users)
|
||||
@@ -162,11 +160,21 @@ export async function resetPassword(
|
||||
.where(eq(passwordResetTokens.email, email));
|
||||
});
|
||||
|
||||
await sendEmail(ConfirmPasswordReset({ email }), {
|
||||
from: config.getNoReplyEmail(),
|
||||
to: email,
|
||||
subject: "Password Reset Confirmation"
|
||||
});
|
||||
try {
|
||||
await invalidateAllSessions(resetRequest[0].userId);
|
||||
} catch (e) {
|
||||
logger.error("Failed to invalidate user sessions", e);
|
||||
}
|
||||
|
||||
try {
|
||||
await sendEmail(ConfirmPasswordReset({ email }), {
|
||||
from: config.getNoReplyEmail(),
|
||||
to: email,
|
||||
subject: "Password Reset Confirmation"
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("Failed to send password reset confirmation email", e);
|
||||
}
|
||||
|
||||
return response<ResetPasswordResponse>(res, {
|
||||
data: null,
|
||||
|
||||
@@ -1,33 +1,38 @@
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { response } from "@server/lib/response";
|
||||
import db from "@server/db";
|
||||
import {
|
||||
ResourceAccessToken,
|
||||
ResourcePassword,
|
||||
resourcePassword,
|
||||
ResourcePincode,
|
||||
resourcePincode,
|
||||
resources,
|
||||
sessions,
|
||||
userOrgs,
|
||||
users
|
||||
} from "@server/db/schema";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import config from "@server/lib/config";
|
||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||
import {
|
||||
createResourceSession,
|
||||
serializeResourceSessionCookie,
|
||||
validateResourceSessionToken
|
||||
} from "@server/auth/sessions/resource";
|
||||
import { Resource, roleResources, userResources } from "@server/db/schema";
|
||||
import logger from "@server/logger";
|
||||
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||
import db from "@server/db";
|
||||
import {
|
||||
Resource,
|
||||
ResourceAccessToken,
|
||||
ResourcePassword,
|
||||
resourcePassword,
|
||||
ResourcePincode,
|
||||
resourcePincode,
|
||||
ResourceRule,
|
||||
resourceRules,
|
||||
resources,
|
||||
roleResources,
|
||||
sessions,
|
||||
userOrgs,
|
||||
userResources,
|
||||
users
|
||||
} from "@server/db/schema";
|
||||
import config from "@server/lib/config";
|
||||
import { isIpInCidr } from "@server/lib/ip";
|
||||
import { response } from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import NodeCache from "node-cache";
|
||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
// We'll see if this speeds anything up
|
||||
const cache = new NodeCache({
|
||||
@@ -79,12 +84,21 @@ export async function verifyResourceSession(
|
||||
host,
|
||||
originalRequestURL,
|
||||
requestIp,
|
||||
path,
|
||||
accessToken: token
|
||||
} = parsedBody.data;
|
||||
|
||||
const clientIp = requestIp?.split(":")[0];
|
||||
|
||||
const resourceCacheKey = `resource:${host}`;
|
||||
let cleanHost = host;
|
||||
// if the host ends with :443 or :80 remove it
|
||||
if (cleanHost.endsWith(":443")) {
|
||||
cleanHost = cleanHost.slice(0, -4);
|
||||
} else if (cleanHost.endsWith(":80")) {
|
||||
cleanHost = cleanHost.slice(0, -3);
|
||||
}
|
||||
|
||||
const resourceCacheKey = `resource:${cleanHost}`;
|
||||
let resourceData:
|
||||
| {
|
||||
resource: Resource | null;
|
||||
@@ -105,11 +119,11 @@ export async function verifyResourceSession(
|
||||
resourcePassword,
|
||||
eq(resourcePassword.resourceId, resources.resourceId)
|
||||
)
|
||||
.where(eq(resources.fullDomain, host))
|
||||
.where(eq(resources.fullDomain, cleanHost))
|
||||
.limit(1);
|
||||
|
||||
if (!result) {
|
||||
logger.debug("Resource not found", host);
|
||||
logger.debug("Resource not found", cleanHost);
|
||||
return notAllowed(res);
|
||||
}
|
||||
|
||||
@@ -125,7 +139,7 @@ export async function verifyResourceSession(
|
||||
const { resource, pincode, password } = resourceData;
|
||||
|
||||
if (!resource) {
|
||||
logger.debug("Resource not found", host);
|
||||
logger.debug("Resource not found", cleanHost);
|
||||
return notAllowed(res);
|
||||
}
|
||||
|
||||
@@ -136,6 +150,25 @@ export async function verifyResourceSession(
|
||||
return notAllowed(res);
|
||||
}
|
||||
|
||||
// check the rules
|
||||
if (resource.applyRules) {
|
||||
const action = await checkRules(
|
||||
resource.resourceId,
|
||||
clientIp,
|
||||
path
|
||||
);
|
||||
|
||||
if (action == "ACCEPT") {
|
||||
logger.debug("Resource allowed by rule");
|
||||
return allowed(res);
|
||||
} else if (action == "DROP") {
|
||||
logger.debug("Resource denied by rule");
|
||||
return notAllowed(res);
|
||||
}
|
||||
|
||||
// otherwise its undefined and we pass
|
||||
}
|
||||
|
||||
if (
|
||||
!resource.sso &&
|
||||
!pincode &&
|
||||
@@ -146,18 +179,16 @@ export async function verifyResourceSession(
|
||||
return allowed(res);
|
||||
}
|
||||
|
||||
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`;
|
||||
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(
|
||||
resource.resourceId
|
||||
)}?redirect=${encodeURIComponent(originalRequestURL)}`;
|
||||
|
||||
// check for access token
|
||||
let validAccessToken: ResourceAccessToken | undefined;
|
||||
if (token) {
|
||||
const [accessTokenId, accessToken] = token.split(".");
|
||||
const { valid, error, tokenItem } = await verifyResourceAccessToken(
|
||||
{
|
||||
resource,
|
||||
accessTokenId,
|
||||
accessToken
|
||||
}
|
||||
{ resource, accessTokenId, accessToken }
|
||||
);
|
||||
|
||||
if (error) {
|
||||
@@ -167,7 +198,9 @@ export async function verifyResourceSession(
|
||||
if (!valid) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Resource access token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
|
||||
`Resource access token is invalid. Resource ID: ${
|
||||
resource.resourceId
|
||||
}. IP: ${clientIp}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -188,7 +221,9 @@ export async function verifyResourceSession(
|
||||
if (!sessions) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Missing resource sessions. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
|
||||
`Missing resource sessions. Resource ID: ${
|
||||
resource.resourceId
|
||||
}. IP: ${clientIp}.`
|
||||
);
|
||||
}
|
||||
return notAllowed(res);
|
||||
@@ -196,7 +231,9 @@ export async function verifyResourceSession(
|
||||
|
||||
const resourceSessionToken =
|
||||
sessions[
|
||||
`${config.getRawConfig().server.session_cookie_name}${resource.ssl ? "_s" : ""}`
|
||||
`${config.getRawConfig().server.session_cookie_name}${
|
||||
resource.ssl ? "_s" : ""
|
||||
}`
|
||||
];
|
||||
|
||||
if (resourceSessionToken) {
|
||||
@@ -219,7 +256,9 @@ export async function verifyResourceSession(
|
||||
);
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Resource session is an exchange token. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
|
||||
`Resource session is an exchange token. Resource ID: ${
|
||||
resource.resourceId
|
||||
}. IP: ${clientIp}.`
|
||||
);
|
||||
}
|
||||
return notAllowed(res);
|
||||
@@ -258,7 +297,9 @@ export async function verifyResourceSession(
|
||||
}
|
||||
|
||||
if (resourceSession.userSessionId && sso) {
|
||||
const userAccessCacheKey = `userAccess:${resourceSession.userSessionId}:${resource.resourceId}`;
|
||||
const userAccessCacheKey = `userAccess:${
|
||||
resourceSession.userSessionId
|
||||
}:${resource.resourceId}`;
|
||||
|
||||
let isAllowed: boolean | undefined =
|
||||
cache.get(userAccessCacheKey);
|
||||
@@ -282,8 +323,8 @@ export async function verifyResourceSession(
|
||||
}
|
||||
}
|
||||
|
||||
// At this point we have checked all sessions, but since the access token is valid, we should allow access
|
||||
// and create a new session.
|
||||
// At this point we have checked all sessions, but since the access token is
|
||||
// valid, we should allow access and create a new session.
|
||||
if (validAccessToken) {
|
||||
return await createAccessTokenSession(
|
||||
res,
|
||||
@@ -296,7 +337,9 @@ export async function verifyResourceSession(
|
||||
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Resource access not allowed. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
|
||||
`Resource access not allowed. Resource ID: ${
|
||||
resource.resourceId
|
||||
}. IP: ${clientIp}.`
|
||||
);
|
||||
}
|
||||
return notAllowed(res, redirectUrl);
|
||||
@@ -438,3 +481,147 @@ async function isUserAllowedToAccessResource(
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function checkRules(
|
||||
resourceId: number,
|
||||
clientIp: string | undefined,
|
||||
path: string | undefined
|
||||
): Promise<"ACCEPT" | "DROP" | undefined> {
|
||||
const ruleCacheKey = `rules:${resourceId}`;
|
||||
|
||||
let rules: ResourceRule[] | undefined = cache.get(ruleCacheKey);
|
||||
|
||||
if (!rules) {
|
||||
rules = await db
|
||||
.select()
|
||||
.from(resourceRules)
|
||||
.where(eq(resourceRules.resourceId, resourceId));
|
||||
|
||||
cache.set(ruleCacheKey, rules);
|
||||
}
|
||||
|
||||
if (rules.length === 0) {
|
||||
logger.debug("No rules found for resource", resourceId);
|
||||
return;
|
||||
}
|
||||
|
||||
// sort rules by priority in ascending order
|
||||
rules = rules.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!rule.enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
clientIp &&
|
||||
rule.match == "CIDR" &&
|
||||
isIpInCidr(clientIp, rule.value)
|
||||
) {
|
||||
return rule.action as any;
|
||||
} else if (clientIp && rule.match == "IP" && clientIp == rule.value) {
|
||||
return rule.action as any;
|
||||
} else if (
|
||||
path &&
|
||||
rule.match == "PATH" &&
|
||||
isPathAllowed(rule.value, path)
|
||||
) {
|
||||
return rule.action as any;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
function isPathAllowed(pattern: string, path: string): boolean {
|
||||
logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`);
|
||||
|
||||
// Normalize and split paths into segments
|
||||
const normalize = (p: string) => p.split("/").filter(Boolean);
|
||||
const patternParts = normalize(pattern);
|
||||
const pathParts = normalize(path);
|
||||
|
||||
logger.debug(`Normalized pattern parts: [${patternParts.join(", ")}]`);
|
||||
logger.debug(`Normalized path parts: [${pathParts.join(", ")}]`);
|
||||
|
||||
// Recursive function to try different wildcard matches
|
||||
function matchSegments(patternIndex: number, pathIndex: number): boolean {
|
||||
const indent = " ".repeat(pathIndex); // Indent based on recursion depth
|
||||
const currentPatternPart = patternParts[patternIndex];
|
||||
const currentPathPart = pathParts[pathIndex];
|
||||
|
||||
logger.debug(
|
||||
`${indent}Checking patternIndex=${patternIndex} (${currentPatternPart || "END"}) vs pathIndex=${pathIndex} (${currentPathPart || "END"})`
|
||||
);
|
||||
|
||||
// If we've consumed all pattern parts, we should have consumed all path parts
|
||||
if (patternIndex >= patternParts.length) {
|
||||
const result = pathIndex >= pathParts.length;
|
||||
logger.debug(
|
||||
`${indent}Reached end of pattern, remaining path: ${pathParts.slice(pathIndex).join("/")} -> ${result}`
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// If we've consumed all path parts but still have pattern parts
|
||||
if (pathIndex >= pathParts.length) {
|
||||
// The only way this can match is if all remaining pattern parts are wildcards
|
||||
const remainingPattern = patternParts.slice(patternIndex);
|
||||
const result = remainingPattern.every((p) => p === "*");
|
||||
logger.debug(
|
||||
`${indent}Reached end of path, remaining pattern: ${remainingPattern.join("/")} -> ${result}`
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// For wildcards, try consuming different numbers of path segments
|
||||
if (currentPatternPart === "*") {
|
||||
logger.debug(
|
||||
`${indent}Found wildcard at pattern index ${patternIndex}`
|
||||
);
|
||||
|
||||
// Try consuming 0 segments (skip the wildcard)
|
||||
logger.debug(
|
||||
`${indent}Trying to skip wildcard (consume 0 segments)`
|
||||
);
|
||||
if (matchSegments(patternIndex + 1, pathIndex)) {
|
||||
logger.debug(
|
||||
`${indent}Successfully matched by skipping wildcard`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try consuming current segment and recursively try rest
|
||||
logger.debug(
|
||||
`${indent}Trying to consume segment "${currentPathPart}" for wildcard`
|
||||
);
|
||||
if (matchSegments(patternIndex, pathIndex + 1)) {
|
||||
logger.debug(
|
||||
`${indent}Successfully matched by consuming segment for wildcard`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.debug(`${indent}Failed to match wildcard`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// For regular segments, they must match exactly
|
||||
if (currentPatternPart !== currentPathPart) {
|
||||
logger.debug(
|
||||
`${indent}Segment mismatch: "${currentPatternPart}" != "${currentPathPart}"`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`${indent}Segments match: "${currentPatternPart}" = "${currentPathPart}"`
|
||||
);
|
||||
// Move to next segments in both pattern and path
|
||||
return matchSegments(patternIndex + 1, pathIndex + 1);
|
||||
}
|
||||
|
||||
const result = matchSegments(0, 0);
|
||||
logger.debug(`Final result: ${result}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
1
server/routers/domain/index.ts
Normal file
1
server/routers/domain/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./listDomains";
|
||||
109
server/routers/domain/listDomains.ts
Normal file
109
server/routers/domain/listDomains.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { domains, orgDomains, users } from "@server/db/schema";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const listDomainsParamsSchema = z
|
||||
.object({
|
||||
orgId: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
||||
const listDomainsSchema = z
|
||||
.object({
|
||||
limit: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().nonnegative()),
|
||||
offset: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().nonnegative())
|
||||
})
|
||||
.strict();
|
||||
|
||||
async function queryDomains(orgId: string, limit: number, offset: number) {
|
||||
const res = await db
|
||||
.select({
|
||||
domainId: domains.domainId,
|
||||
baseDomain: domains.baseDomain
|
||||
})
|
||||
.from(orgDomains)
|
||||
.where(eq(orgDomains.orgId, orgId))
|
||||
.innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
return res;
|
||||
}
|
||||
|
||||
export type ListDomainsResponse = {
|
||||
domains: NonNullable<Awaited<ReturnType<typeof queryDomains>>>;
|
||||
pagination: { total: number; limit: number; offset: number };
|
||||
};
|
||||
|
||||
export async function listDomains(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listDomainsSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
|
||||
const parsedParams = listDomainsParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
const domains = await queryDomains(orgId.toString(), limit, offset);
|
||||
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users);
|
||||
|
||||
return response<ListDomainsResponse>(res, {
|
||||
data: {
|
||||
domains,
|
||||
pagination: {
|
||||
total: count,
|
||||
limit,
|
||||
offset
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Users retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import config from "@server/lib/config";
|
||||
import * as site from "./site";
|
||||
import * as org from "./org";
|
||||
import * as resource from "./resource";
|
||||
import * as domain from "./domain";
|
||||
import * as target from "./target";
|
||||
import * as user from "./user";
|
||||
import * as auth from "./auth";
|
||||
@@ -27,6 +28,8 @@ import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import { verifyUserIsOrgOwner } from "../middlewares/verifyUserIsOrgOwner";
|
||||
import { createNewt, getToken } from "./newt";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import createHttpError from "http-errors";
|
||||
|
||||
// Root routes
|
||||
export const unauthenticated = Router();
|
||||
@@ -131,6 +134,13 @@ authenticated.get(
|
||||
resource.listResources
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/domains",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.listOrgDomains),
|
||||
domain.listDomains
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/create-invite",
|
||||
verifyOrgAccess,
|
||||
@@ -184,6 +194,32 @@ authenticated.get(
|
||||
verifyUserHasAction(ActionsEnum.listTargets),
|
||||
target.listTargets
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource/:resourceId/rule",
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.createResourceRule),
|
||||
resource.createResourceRule
|
||||
);
|
||||
authenticated.get(
|
||||
"/resource/:resourceId/rules",
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.listResourceRules),
|
||||
resource.listResourceRules
|
||||
);
|
||||
authenticated.post(
|
||||
"/resource/:resourceId/rule/:ruleId",
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateResourceRule),
|
||||
resource.updateResourceRule
|
||||
);
|
||||
authenticated.delete(
|
||||
"/resource/:resourceId/rule/:ruleId",
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteResourceRule),
|
||||
resource.deleteResourceRule
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/target/:targetId",
|
||||
verifyTargetAccess,
|
||||
@@ -203,6 +239,7 @@ authenticated.delete(
|
||||
target.deleteTarget
|
||||
);
|
||||
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/role",
|
||||
verifyOrgAccess,
|
||||
@@ -308,6 +345,13 @@ authenticated.get(
|
||||
resource.getResourceWhitelist
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/transfer`,
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateResource),
|
||||
resource.transferResource
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/access-token`,
|
||||
verifyResourceAccess,
|
||||
@@ -445,22 +489,61 @@ authRouter.post(
|
||||
);
|
||||
authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa);
|
||||
authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail);
|
||||
|
||||
authRouter.post(
|
||||
"/verify-email/request",
|
||||
verifySessionMiddleware,
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 3,
|
||||
keyGenerator: (req) => `requestEmailVerificationCode:${req.body.email}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only request an email verification code ${3} times every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
}
|
||||
}),
|
||||
auth.requestEmailVerificationCode
|
||||
);
|
||||
|
||||
// authRouter.post(
|
||||
// "/change-password",
|
||||
// verifySessionUserMiddleware,
|
||||
// auth.changePassword
|
||||
// );
|
||||
authRouter.post("/reset-password/request", auth.requestPasswordReset);
|
||||
|
||||
authRouter.post(
|
||||
"/reset-password/request",
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 3,
|
||||
keyGenerator: (req) => `requestPasswordReset:${req.body.email}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only request a password reset ${3} times every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
}
|
||||
}),
|
||||
auth.requestPasswordReset
|
||||
);
|
||||
|
||||
authRouter.post("/reset-password/", auth.resetPassword);
|
||||
|
||||
authRouter.post("/resource/:resourceId/password", resource.authWithPassword);
|
||||
authRouter.post("/resource/:resourceId/pincode", resource.authWithPincode);
|
||||
authRouter.post("/resource/:resourceId/whitelist", resource.authWithWhitelist);
|
||||
|
||||
authRouter.post(
|
||||
"/resource/:resourceId/whitelist",
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
keyGenerator: (req) => `authWithWhitelist:${req.body.email}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only request an email OTP ${10} times every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
}
|
||||
}),
|
||||
resource.authWithWhitelist
|
||||
);
|
||||
|
||||
authRouter.post(
|
||||
"/resource/:resourceId/access-token",
|
||||
resource.authWithAccessToken
|
||||
|
||||
@@ -11,6 +11,7 @@ import config from "@server/lib/config";
|
||||
import { getUniqueExitNodeEndpointName } from '@server/db/names';
|
||||
import { findNextAvailableCidr } from "@server/lib/ip";
|
||||
import { fromError } from 'zod-validation-error';
|
||||
import { getAllowedIps } from '../target/helpers';
|
||||
// Define Zod schema for request validation
|
||||
const getConfigSchema = z.object({
|
||||
publicKey: z.string(),
|
||||
@@ -83,22 +84,9 @@ export async function getConfig(req: Request, res: Response, next: NextFunction)
|
||||
});
|
||||
|
||||
const peers = await Promise.all(sitesRes.map(async (site) => {
|
||||
// Fetch resources for this site
|
||||
const resourcesRes = await db.query.resources.findMany({
|
||||
where: eq(resources.siteId, site.siteId),
|
||||
});
|
||||
|
||||
// Fetch targets for all resources of this site
|
||||
const targetIps = await Promise.all(resourcesRes.map(async (resource) => {
|
||||
const targetsRes = await db.query.targets.findMany({
|
||||
where: eq(targets.resourceId, resource.resourceId),
|
||||
});
|
||||
return targetsRes.map(target => `${target.ip}/32`);
|
||||
}));
|
||||
|
||||
return {
|
||||
publicKey: site.pubKey,
|
||||
allowedIps: targetIps.flat(),
|
||||
allowedIps: await getAllowedIps(site.siteId)
|
||||
};
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Target } from "@server/db/schema";
|
||||
import { sendToClient } from "../ws";
|
||||
|
||||
export async function addTargets(
|
||||
export function addTargets(
|
||||
newtId: string,
|
||||
targets: Target[],
|
||||
protocol: string
|
||||
): Promise<void> {
|
||||
) {
|
||||
//create a list of udp and tcp targets
|
||||
const payloadTargets = targets.map((target) => {
|
||||
return `${target.internalPort ? target.internalPort + ":" : ""}${
|
||||
@@ -22,11 +22,11 @@ export async function addTargets(
|
||||
sendToClient(newtId, payload);
|
||||
}
|
||||
|
||||
export async function removeTargets(
|
||||
export function removeTargets(
|
||||
newtId: string,
|
||||
targets: Target[],
|
||||
protocol: string
|
||||
): Promise<void> {
|
||||
) {
|
||||
//create a list of udp and tcp targets
|
||||
const payloadTargets = targets.map((target) => {
|
||||
return `${target.internalPort ? target.internalPort + ":" : ""}${
|
||||
|
||||
@@ -2,7 +2,15 @@ import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Org, orgs, roleActions, roles, userOrgs } from "@server/db/schema";
|
||||
import {
|
||||
domains,
|
||||
Org,
|
||||
orgDomains,
|
||||
orgs,
|
||||
roleActions,
|
||||
roles,
|
||||
userOrgs
|
||||
} from "@server/db/schema";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -16,7 +24,6 @@ const createOrgSchema = z
|
||||
.object({
|
||||
orgId: z.string(),
|
||||
name: z.string().min(1).max(255)
|
||||
// domain: z.string().min(1).max(255).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -82,14 +89,16 @@ export async function createOrg(
|
||||
let org: Org | null = null;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const domain = config.getBaseDomain();
|
||||
const allDomains = await trx
|
||||
.select()
|
||||
.from(domains)
|
||||
.where(eq(domains.configManaged, true));
|
||||
|
||||
const newOrg = await trx
|
||||
.insert(orgs)
|
||||
.values({
|
||||
orgId,
|
||||
name,
|
||||
domain
|
||||
name
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -109,6 +118,13 @@ export async function createOrg(
|
||||
return;
|
||||
}
|
||||
|
||||
await trx.insert(orgDomains).values(
|
||||
allDomains.map((domain) => ({
|
||||
orgId: newOrg[0].orgId,
|
||||
domainId: domain.domainId
|
||||
}))
|
||||
);
|
||||
|
||||
await trx.insert(userOrgs).values({
|
||||
userId: req.user!.userId,
|
||||
orgId: newOrg[0].orgId,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { SqliteError } from "better-sqlite3";
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import {
|
||||
domains,
|
||||
orgDomains,
|
||||
orgs,
|
||||
Resource,
|
||||
resources,
|
||||
@@ -17,7 +18,8 @@ import { eq, and } from "drizzle-orm";
|
||||
import stoi from "@server/lib/stoi";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
||||
import { subdomainSchema } from "@server/lib/schemas";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
const createResourceParamsSchema = z
|
||||
.object({
|
||||
@@ -26,42 +28,63 @@ const createResourceParamsSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const createResourceSchema = z
|
||||
const createHttpResourceSchema = z
|
||||
.object({
|
||||
subdomain: z.string().optional(),
|
||||
name: z.string().min(1).max(255),
|
||||
subdomain: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val?.toLowerCase()),
|
||||
isBaseDomain: z.boolean().optional(),
|
||||
siteId: z.number(),
|
||||
http: z.boolean(),
|
||||
protocol: z.string(),
|
||||
proxyPort: z.number().optional()
|
||||
domainId: z.string()
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data.http) {
|
||||
return z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(65535)
|
||||
.safeParse(data.proxyPort).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Invalid port number",
|
||||
path: ["proxyPort"]
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.http) {
|
||||
if (data.subdomain) {
|
||||
return subdomainSchema.safeParse(data.subdomain).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: "Invalid subdomain" }
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!config.getRawConfig().flags?.allow_base_domain_resources) {
|
||||
if (data.isBaseDomain) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Invalid subdomain",
|
||||
path: ["subdomain"]
|
||||
message: "Base domain resources are not allowed"
|
||||
}
|
||||
);
|
||||
|
||||
const createRawResourceSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
siteId: z.number(),
|
||||
http: z.boolean(),
|
||||
protocol: z.string(),
|
||||
proxyPort: z.number().int().min(1).max(65535)
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!config.getRawConfig().flags?.allow_raw_resources) {
|
||||
if (data.proxyPort !== undefined) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Proxy port cannot be set"
|
||||
}
|
||||
);
|
||||
|
||||
@@ -73,18 +96,6 @@ export async function createResource(
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedBody = createResourceSchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let { name, subdomain, protocol, proxyPort, http } = parsedBody.data;
|
||||
|
||||
// Validate request params
|
||||
const parsedParams = createResourceParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
@@ -120,101 +131,25 @@ export async function createResource(
|
||||
);
|
||||
}
|
||||
|
||||
const fullDomain = `${subdomain}.${org[0].domain}`;
|
||||
// if http is false check to see if there is already a resource with the same port and protocol
|
||||
if (!http) {
|
||||
const existingResource = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.protocol, protocol),
|
||||
eq(resources.proxyPort, proxyPort!)
|
||||
)
|
||||
);
|
||||
|
||||
if (existingResource.length > 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"Resource with that protocol and port already exists"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (proxyPort === 443 || proxyPort === 80) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Port 80 and 443 are reserved for https resources"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// make sure the full domain is unique
|
||||
const existingResource = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.fullDomain, fullDomain));
|
||||
|
||||
if (existingResource.length > 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"Resource with that domain already exists"
|
||||
)
|
||||
);
|
||||
}
|
||||
if (typeof req.body.http !== "boolean") {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "http field is required")
|
||||
);
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const newResource = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
siteId,
|
||||
fullDomain: http ? fullDomain : null,
|
||||
orgId,
|
||||
name,
|
||||
subdomain,
|
||||
http,
|
||||
protocol,
|
||||
proxyPort,
|
||||
ssl: true
|
||||
})
|
||||
.returning();
|
||||
const { http } = req.body;
|
||||
|
||||
const adminRole = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (adminRole.length === 0) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
|
||||
);
|
||||
}
|
||||
|
||||
await trx.insert(roleResources).values({
|
||||
roleId: adminRole[0].roleId,
|
||||
resourceId: newResource[0].resourceId
|
||||
});
|
||||
|
||||
if (req.userOrgRoleId != adminRole[0].roleId) {
|
||||
// make sure the user can access the resource
|
||||
await trx.insert(userResources).values({
|
||||
userId: req.user?.userId!,
|
||||
resourceId: newResource[0].resourceId
|
||||
});
|
||||
}
|
||||
response<CreateResourceResponse>(res, {
|
||||
data: newResource[0],
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
});
|
||||
if (http) {
|
||||
return await createHttpResource(
|
||||
{ req, res, next },
|
||||
{ siteId, orgId }
|
||||
);
|
||||
} else {
|
||||
return await createRawResource(
|
||||
{ req, res, next },
|
||||
{ siteId, orgId }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
@@ -222,3 +157,245 @@ export async function createResource(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function createHttpResource(
|
||||
route: {
|
||||
req: Request;
|
||||
res: Response;
|
||||
next: NextFunction;
|
||||
},
|
||||
meta: {
|
||||
siteId: number;
|
||||
orgId: string;
|
||||
}
|
||||
) {
|
||||
const { req, res, next } = route;
|
||||
const { siteId, orgId } = meta;
|
||||
|
||||
const parsedBody = createHttpResourceSchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { name, subdomain, isBaseDomain, http, protocol, domainId } =
|
||||
parsedBody.data;
|
||||
|
||||
const [orgDomain] = await db
|
||||
.select()
|
||||
.from(orgDomains)
|
||||
.where(
|
||||
and(eq(orgDomains.orgId, orgId), eq(orgDomains.domainId, domainId))
|
||||
)
|
||||
.leftJoin(domains, eq(orgDomains.domainId, domains.domainId));
|
||||
|
||||
if (!orgDomain || !orgDomain.domains) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Domain with ID ${parsedBody.data.domainId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const domain = orgDomain.domains;
|
||||
|
||||
let fullDomain = "";
|
||||
if (isBaseDomain) {
|
||||
fullDomain = domain.baseDomain;
|
||||
} else {
|
||||
fullDomain = `${subdomain}.${domain.baseDomain}`;
|
||||
}
|
||||
|
||||
logger.debug(`Full domain: ${fullDomain}`);
|
||||
|
||||
// make sure the full domain is unique
|
||||
const existingResource = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.fullDomain, fullDomain));
|
||||
|
||||
if (existingResource.length > 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"Resource with that domain already exists"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let resource: Resource | undefined;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const newResource = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
siteId,
|
||||
fullDomain,
|
||||
domainId,
|
||||
orgId,
|
||||
name,
|
||||
subdomain,
|
||||
http,
|
||||
protocol,
|
||||
ssl: true,
|
||||
isBaseDomain
|
||||
})
|
||||
.returning();
|
||||
|
||||
const adminRole = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (adminRole.length === 0) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
|
||||
);
|
||||
}
|
||||
|
||||
await trx.insert(roleResources).values({
|
||||
roleId: adminRole[0].roleId,
|
||||
resourceId: newResource[0].resourceId
|
||||
});
|
||||
|
||||
if (req.userOrgRoleId != adminRole[0].roleId) {
|
||||
// make sure the user can access the resource
|
||||
await trx.insert(userResources).values({
|
||||
userId: req.user?.userId!,
|
||||
resourceId: newResource[0].resourceId
|
||||
});
|
||||
}
|
||||
|
||||
resource = newResource[0];
|
||||
});
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response<CreateResourceResponse>(res, {
|
||||
data: resource,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Http resource created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
}
|
||||
|
||||
async function createRawResource(
|
||||
route: {
|
||||
req: Request;
|
||||
res: Response;
|
||||
next: NextFunction;
|
||||
},
|
||||
meta: {
|
||||
siteId: number;
|
||||
orgId: string;
|
||||
}
|
||||
) {
|
||||
const { req, res, next } = route;
|
||||
const { siteId, orgId } = meta;
|
||||
|
||||
const parsedBody = createRawResourceSchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { name, http, protocol, proxyPort } = parsedBody.data;
|
||||
|
||||
// if http is false check to see if there is already a resource with the same port and protocol
|
||||
const existingResource = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.protocol, protocol),
|
||||
eq(resources.proxyPort, proxyPort!)
|
||||
)
|
||||
);
|
||||
|
||||
if (existingResource.length > 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"Resource with that protocol and port already exists"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let resource: Resource | undefined;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const newResource = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
siteId,
|
||||
orgId,
|
||||
name,
|
||||
http,
|
||||
protocol,
|
||||
proxyPort
|
||||
})
|
||||
.returning();
|
||||
|
||||
const adminRole = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (adminRole.length === 0) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
|
||||
);
|
||||
}
|
||||
|
||||
await trx.insert(roleResources).values({
|
||||
roleId: adminRole[0].roleId,
|
||||
resourceId: newResource[0].resourceId
|
||||
});
|
||||
|
||||
if (req.userOrgRoleId != adminRole[0].roleId) {
|
||||
// make sure the user can access the resource
|
||||
await trx.insert(userResources).values({
|
||||
userId: req.user?.userId!,
|
||||
resourceId: newResource[0].resourceId
|
||||
});
|
||||
}
|
||||
|
||||
resource = newResource[0];
|
||||
});
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response<CreateResourceResponse>(res, {
|
||||
data: resource,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Non-http resource created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
}
|
||||
|
||||
145
server/routers/resource/createResourceRule.ts
Normal file
145
server/routers/resource/createResourceRule.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { resourceRules, resources } from "@server/db/schema";
|
||||
import { 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 {
|
||||
isValidCIDR,
|
||||
isValidIP,
|
||||
isValidUrlGlobPattern
|
||||
} from "@server/lib/validators";
|
||||
|
||||
const createResourceRuleSchema = z
|
||||
.object({
|
||||
action: z.enum(["ACCEPT", "DROP"]),
|
||||
match: z.enum(["CIDR", "IP", "PATH"]),
|
||||
value: z.string().min(1),
|
||||
priority: z.number().int(),
|
||||
enabled: z.boolean().optional()
|
||||
})
|
||||
.strict();
|
||||
|
||||
const createResourceRuleParamsSchema = z
|
||||
.object({
|
||||
resourceId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function createResourceRule(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedBody = createResourceRuleSchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { action, match, value, priority, enabled } = parsedBody.data;
|
||||
|
||||
const parsedParams = createResourceRuleParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourceId } = parsedParams.data;
|
||||
|
||||
// Verify that the referenced resource exists
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!resource.http) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Cannot create rule for non-http resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (match === "CIDR") {
|
||||
if (!isValidCIDR(value)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid CIDR provided"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (match === "IP") {
|
||||
if (!isValidIP(value)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
|
||||
);
|
||||
}
|
||||
} else if (match === "PATH") {
|
||||
if (!isValidUrlGlobPattern(value)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid URL glob pattern provided"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the new resource rule
|
||||
const [newRule] = await db
|
||||
.insert(resourceRules)
|
||||
.values({
|
||||
resourceId,
|
||||
action,
|
||||
match,
|
||||
value,
|
||||
priority,
|
||||
enabled
|
||||
})
|
||||
.returning();
|
||||
|
||||
return response(res, {
|
||||
data: newRule,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource rule created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { addPeer } from "../gerbil/peers";
|
||||
import { removeTargets } from "../newt/targets";
|
||||
import { getAllowedIps } from "../target/helpers";
|
||||
|
||||
// Define Zod schema for request parameters validation
|
||||
const deleteResourceSchema = z
|
||||
@@ -75,25 +76,9 @@ export async function deleteResource(
|
||||
|
||||
if (site.pubKey) {
|
||||
if (site.type == "wireguard") {
|
||||
// TODO: is this all inefficient?
|
||||
// Fetch resources for this site
|
||||
const resourcesRes = await db.query.resources.findMany({
|
||||
where: eq(resources.siteId, site.siteId)
|
||||
});
|
||||
|
||||
// Fetch targets for all resources of this site
|
||||
const targetIps = await Promise.all(
|
||||
resourcesRes.map(async (resource) => {
|
||||
const targetsRes = await db.query.targets.findMany({
|
||||
where: eq(targets.resourceId, resource.resourceId)
|
||||
});
|
||||
return targetsRes.map((target) => `${target.ip}/32`);
|
||||
})
|
||||
);
|
||||
|
||||
await addPeer(site.exitNodeId!, {
|
||||
publicKey: site.pubKey,
|
||||
allowedIps: targetIps.flat()
|
||||
allowedIps: await getAllowedIps(site.siteId)
|
||||
});
|
||||
} else if (site.type == "newt") {
|
||||
// get the newt on the site by querying the newt table for siteId
|
||||
|
||||
71
server/routers/resource/deleteResourceRule.ts
Normal file
71
server/routers/resource/deleteResourceRule.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { resourceRules, resources } from "@server/db/schema";
|
||||
import { 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";
|
||||
|
||||
const deleteResourceRuleSchema = z
|
||||
.object({
|
||||
ruleId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive()),
|
||||
resourceId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function deleteResourceRule(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = deleteResourceRuleSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { ruleId } = parsedParams.data;
|
||||
|
||||
// Delete the rule and return the deleted record
|
||||
const [deletedRule] = await db
|
||||
.delete(resourceRules)
|
||||
.where(eq(resourceRules.ruleId, ruleId))
|
||||
.returning();
|
||||
|
||||
if (!deletedRule) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource rule with ID ${ruleId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource rule deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,4 +16,9 @@ export * from "./setResourceWhitelist";
|
||||
export * from "./getResourceWhitelist";
|
||||
export * from "./authWithWhitelist";
|
||||
export * from "./authWithAccessToken";
|
||||
export * from "./transferResource";
|
||||
export * from "./getExchangeToken";
|
||||
export * from "./createResourceRule";
|
||||
export * from "./deleteResourceRule";
|
||||
export * from "./listResourceRules";
|
||||
export * from "./updateResourceRule";
|
||||
139
server/routers/resource/listResourceRules.ts
Normal file
139
server/routers/resource/listResourceRules.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { db } from "@server/db";
|
||||
import { resourceRules, resources } from "@server/db/schema";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
|
||||
const listResourceRulesParamsSchema = z
|
||||
.object({
|
||||
resourceId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
})
|
||||
.strict();
|
||||
|
||||
const listResourceRulesSchema = z.object({
|
||||
limit: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive()),
|
||||
offset: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().nonnegative())
|
||||
});
|
||||
|
||||
function queryResourceRules(resourceId: number) {
|
||||
let baseQuery = db
|
||||
.select({
|
||||
ruleId: resourceRules.ruleId,
|
||||
resourceId: resourceRules.resourceId,
|
||||
action: resourceRules.action,
|
||||
match: resourceRules.match,
|
||||
value: resourceRules.value,
|
||||
priority: resourceRules.priority,
|
||||
enabled: resourceRules.enabled
|
||||
})
|
||||
.from(resourceRules)
|
||||
.leftJoin(resources, eq(resourceRules.resourceId, resources.resourceId))
|
||||
.where(eq(resourceRules.resourceId, resourceId));
|
||||
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
export type ListResourceRulesResponse = {
|
||||
rules: Awaited<ReturnType<typeof queryResourceRules>>;
|
||||
pagination: { total: number; limit: number; offset: number };
|
||||
};
|
||||
|
||||
export async function listResourceRules(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listResourceRulesSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
|
||||
const parsedParams = listResourceRulesParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
const { resourceId } = parsedParams.data;
|
||||
|
||||
// Verify the resource exists
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const baseQuery = queryResourceRules(resourceId);
|
||||
|
||||
let countQuery = db
|
||||
.select({ count: sql<number>`cast(count(*) as integer)` })
|
||||
.from(resourceRules)
|
||||
.where(eq(resourceRules.resourceId, resourceId));
|
||||
|
||||
let rulesList = await baseQuery.limit(limit).offset(offset);
|
||||
const totalCountResult = await countQuery;
|
||||
const totalCount = totalCountResult[0].count;
|
||||
|
||||
// sort rules list by the priority in ascending order
|
||||
rulesList = rulesList.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
return response<ListResourceRulesResponse>(res, {
|
||||
data: {
|
||||
rules: rulesList,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
limit,
|
||||
offset
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource rules retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
192
server/routers/resource/transferResource.ts
Normal file
192
server/routers/resource/transferResource.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { newts, resources, sites, targets } from "@server/db/schema";
|
||||
import { 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 { addPeer } from "../gerbil/peers";
|
||||
import { addTargets, removeTargets } from "../newt/targets";
|
||||
import { getAllowedIps } from "../target/helpers";
|
||||
|
||||
const transferResourceParamsSchema = z
|
||||
.object({
|
||||
resourceId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
})
|
||||
.strict();
|
||||
|
||||
const transferResourceBodySchema = z
|
||||
.object({
|
||||
siteId: z.number().int().positive()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function transferResource(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = transferResourceParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = transferResourceBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourceId } = parsedParams.data;
|
||||
const { siteId } = parsedBody.data;
|
||||
|
||||
const [oldResource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
if (!oldResource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (oldResource.siteId === siteId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Resource is already assigned to this site`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [newSite] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!newSite) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site with ID ${siteId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [oldSite] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, oldResource.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!oldSite) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site with ID ${oldResource.siteId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [updatedResource] = await db
|
||||
.update(resources)
|
||||
.set({ siteId })
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.returning();
|
||||
|
||||
if (!updatedResource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const resourceTargets = await db
|
||||
.select()
|
||||
.from(targets)
|
||||
.where(eq(targets.resourceId, resourceId));
|
||||
|
||||
if (resourceTargets.length > 0) {
|
||||
////// REMOVE THE TARGETS FROM THE OLD SITE //////
|
||||
if (oldSite.pubKey) {
|
||||
if (oldSite.type == "wireguard") {
|
||||
await addPeer(oldSite.exitNodeId!, {
|
||||
publicKey: oldSite.pubKey,
|
||||
allowedIps: await getAllowedIps(oldSite.siteId)
|
||||
});
|
||||
} else if (oldSite.type == "newt") {
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, oldSite.siteId))
|
||||
.limit(1);
|
||||
|
||||
removeTargets(
|
||||
newt.newtId,
|
||||
resourceTargets,
|
||||
updatedResource.protocol
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
////// ADD THE TARGETS TO THE NEW SITE //////
|
||||
if (newSite.pubKey) {
|
||||
if (newSite.type == "wireguard") {
|
||||
await addPeer(newSite.exitNodeId!, {
|
||||
publicKey: newSite.pubKey,
|
||||
allowedIps: await getAllowedIps(newSite.siteId)
|
||||
});
|
||||
} else if (newSite.type == "newt") {
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, newSite.siteId))
|
||||
.limit(1);
|
||||
|
||||
addTargets(
|
||||
newt.newtId,
|
||||
resourceTargets,
|
||||
updatedResource.protocol
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: updatedResource,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource transferred successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { orgs, resources, sites } from "@server/db/schema";
|
||||
import { eq, or } from "drizzle-orm";
|
||||
import {
|
||||
domains,
|
||||
Org,
|
||||
orgDomains,
|
||||
orgs,
|
||||
Resource,
|
||||
resources
|
||||
} from "@server/db/schema";
|
||||
import { eq, and } 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 { subdomainSchema } from "@server/schemas/subdomainSchema";
|
||||
import config from "@server/lib/config";
|
||||
import { subdomainSchema } from "@server/lib/schemas";
|
||||
|
||||
const updateResourceParamsSchema = z
|
||||
.object({
|
||||
@@ -19,20 +27,69 @@ const updateResourceParamsSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const updateResourceBodySchema = z
|
||||
const updateHttpResourceBodySchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
subdomain: subdomainSchema.optional(),
|
||||
subdomain: subdomainSchema
|
||||
.optional()
|
||||
.transform((val) => val?.toLowerCase()),
|
||||
ssl: z.boolean().optional(),
|
||||
sso: z.boolean().optional(),
|
||||
blockAccess: z.boolean().optional(),
|
||||
proxyPort: z.number().int().min(1).max(65535).optional(),
|
||||
emailWhitelistEnabled: z.boolean().optional()
|
||||
emailWhitelistEnabled: z.boolean().optional(),
|
||||
isBaseDomain: z.boolean().optional(),
|
||||
applyRules: z.boolean().optional(),
|
||||
domainId: z.string().optional()
|
||||
})
|
||||
.strict()
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
message: "At least one field must be provided for update"
|
||||
});
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.subdomain) {
|
||||
return subdomainSchema.safeParse(data.subdomain).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: "Invalid subdomain" }
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!config.getRawConfig().flags?.allow_base_domain_resources) {
|
||||
if (data.isBaseDomain) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Base domain resources are not allowed"
|
||||
}
|
||||
);
|
||||
|
||||
export type UpdateResourceResponse = Resource;
|
||||
|
||||
const updateRawResourceBodySchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
proxyPort: z.number().int().min(1).max(65535).optional()
|
||||
})
|
||||
.strict()
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
message: "At least one field must be provided for update"
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!config.getRawConfig().flags?.allow_raw_resources) {
|
||||
if (data.proxyPort !== undefined) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: "Cannot update proxyPort" }
|
||||
);
|
||||
|
||||
export async function updateResource(
|
||||
req: Request,
|
||||
@@ -50,26 +107,18 @@ export async function updateResource(
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = updateResourceBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourceId } = parsedParams.data;
|
||||
const updateData = parsedBody.data;
|
||||
|
||||
const resource = await db
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.leftJoin(orgs, eq(resources.orgId, orgs.orgId));
|
||||
|
||||
if (resource.length === 0) {
|
||||
const resource = result.resources;
|
||||
const org = result.orgs;
|
||||
|
||||
if (!resource || !org) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
@@ -78,50 +127,33 @@ export async function updateResource(
|
||||
);
|
||||
}
|
||||
|
||||
if (!resource[0].orgs?.domain) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Resource does not have a domain"
|
||||
)
|
||||
if (resource.http) {
|
||||
// HANDLE UPDATING HTTP RESOURCES
|
||||
return await updateHttpResource(
|
||||
{
|
||||
req,
|
||||
res,
|
||||
next
|
||||
},
|
||||
{
|
||||
resource,
|
||||
org
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// HANDLE UPDATING RAW TCP/UDP RESOURCES
|
||||
return await updateRawResource(
|
||||
{
|
||||
req,
|
||||
res,
|
||||
next
|
||||
},
|
||||
{
|
||||
resource,
|
||||
org
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const fullDomain = updateData.subdomain
|
||||
? `${updateData.subdomain}.${resource[0].orgs.domain}`
|
||||
: undefined;
|
||||
|
||||
const updatePayload = {
|
||||
...updateData,
|
||||
...(fullDomain && { fullDomain })
|
||||
};
|
||||
|
||||
const updatedResource = await db
|
||||
.update(resources)
|
||||
.set(updatePayload)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.returning();
|
||||
|
||||
if (updatedResource.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (resource[0].resources.ssl !== updatedResource[0].ssl) {
|
||||
// invalidate all sessions?
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: updatedResource[0],
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
@@ -129,3 +161,186 @@ export async function updateResource(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateHttpResource(
|
||||
route: {
|
||||
req: Request;
|
||||
res: Response;
|
||||
next: NextFunction;
|
||||
},
|
||||
meta: {
|
||||
resource: Resource;
|
||||
org: Org;
|
||||
}
|
||||
) {
|
||||
const { next, req, res } = route;
|
||||
const { resource, org } = meta;
|
||||
|
||||
const parsedBody = updateHttpResourceBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const updateData = parsedBody.data;
|
||||
|
||||
if (updateData.domainId) {
|
||||
const [existingDomain] = await db
|
||||
.select()
|
||||
.from(orgDomains)
|
||||
.where(
|
||||
and(
|
||||
eq(orgDomains.orgId, org.orgId),
|
||||
eq(orgDomains.domainId, updateData.domainId)
|
||||
)
|
||||
)
|
||||
.leftJoin(domains, eq(orgDomains.domainId, domains.domainId));
|
||||
|
||||
if (!existingDomain) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, `Domain not found`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const domainId = updateData.domainId || resource.domainId!;
|
||||
const subdomain = updateData.subdomain || resource.subdomain;
|
||||
|
||||
const [domain] = await db
|
||||
.select()
|
||||
.from(domains)
|
||||
.where(eq(domains.domainId, domainId));
|
||||
|
||||
let fullDomain: string | null = null;
|
||||
if (updateData.isBaseDomain) {
|
||||
fullDomain = domain.baseDomain;
|
||||
} else if (subdomain && domain) {
|
||||
fullDomain = `${subdomain}.${domain.baseDomain}`;
|
||||
}
|
||||
|
||||
if (fullDomain) {
|
||||
const [existingDomain] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.fullDomain, fullDomain));
|
||||
|
||||
if (
|
||||
existingDomain &&
|
||||
existingDomain.resourceId !== resource.resourceId
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"Resource with that domain already exists"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updatePayload = {
|
||||
...updateData,
|
||||
fullDomain
|
||||
};
|
||||
|
||||
const updatedResource = await db
|
||||
.update(resources)
|
||||
.set(updatePayload)
|
||||
.where(eq(resources.resourceId, resource.resourceId))
|
||||
.returning();
|
||||
|
||||
if (updatedResource.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resource.resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: updatedResource[0],
|
||||
success: true,
|
||||
error: false,
|
||||
message: "HTTP resource updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
|
||||
async function updateRawResource(
|
||||
route: {
|
||||
req: Request;
|
||||
res: Response;
|
||||
next: NextFunction;
|
||||
},
|
||||
meta: {
|
||||
resource: Resource;
|
||||
org: Org;
|
||||
}
|
||||
) {
|
||||
const { next, req, res } = route;
|
||||
const { resource } = meta;
|
||||
|
||||
const parsedBody = updateRawResourceBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const updateData = parsedBody.data;
|
||||
|
||||
if (updateData.proxyPort) {
|
||||
const proxyPort = updateData.proxyPort;
|
||||
const existingResource = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.protocol, resource.protocol),
|
||||
eq(resources.proxyPort, proxyPort!)
|
||||
)
|
||||
);
|
||||
|
||||
if (
|
||||
existingResource.length > 0 &&
|
||||
existingResource[0].resourceId !== resource.resourceId
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"Resource with that protocol and port already exists"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedResource = await db
|
||||
.update(resources)
|
||||
.set(updateData)
|
||||
.where(eq(resources.resourceId, resource.resourceId))
|
||||
.returning();
|
||||
|
||||
if (updatedResource.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resource.resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: updatedResource[0],
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Non-http Resource updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
|
||||
179
server/routers/resource/updateResourceRule.ts
Normal file
179
server/routers/resource/updateResourceRule.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { resourceRules, resources } from "@server/db/schema";
|
||||
import { 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 {
|
||||
isValidCIDR,
|
||||
isValidIP,
|
||||
isValidUrlGlobPattern
|
||||
} from "@server/lib/validators";
|
||||
|
||||
// Define Zod schema for request parameters validation
|
||||
const updateResourceRuleParamsSchema = z
|
||||
.object({
|
||||
ruleId: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||
resourceId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Define Zod schema for request body validation
|
||||
const updateResourceRuleSchema = z
|
||||
.object({
|
||||
action: z.enum(["ACCEPT", "DROP"]).optional(),
|
||||
match: z.enum(["CIDR", "IP", "PATH"]).optional(),
|
||||
value: z.string().min(1).optional(),
|
||||
priority: z.number().int(),
|
||||
enabled: z.boolean().optional()
|
||||
})
|
||||
.strict()
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
message: "At least one field must be provided for update"
|
||||
});
|
||||
|
||||
export async function updateResourceRule(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
// Validate path parameters
|
||||
const parsedParams = updateResourceRuleParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Validate request body
|
||||
const parsedBody = updateResourceRuleSchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { ruleId, resourceId } = parsedParams.data;
|
||||
const updateData = parsedBody.data;
|
||||
|
||||
// Verify that the resource exists
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!resource.http) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Cannot create rule for non-http resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Verify that the rule exists and belongs to the specified resource
|
||||
const [existingRule] = await db
|
||||
.select()
|
||||
.from(resourceRules)
|
||||
.where(eq(resourceRules.ruleId, ruleId))
|
||||
.limit(1);
|
||||
|
||||
if (!existingRule) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource rule with ID ${ruleId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (existingRule.resourceId !== resourceId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
`Resource rule ${ruleId} does not belong to resource ${resourceId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const match = updateData.match || existingRule.match;
|
||||
const { value } = updateData;
|
||||
|
||||
if (value !== undefined) {
|
||||
if (match === "CIDR") {
|
||||
if (!isValidCIDR(value)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid CIDR provided"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (match === "IP") {
|
||||
if (!isValidIP(value)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid IP provided"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (match === "PATH") {
|
||||
if (!isValidUrlGlobPattern(value)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid URL glob pattern provided"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the rule
|
||||
const [updatedRule] = await db
|
||||
.update(resourceRules)
|
||||
.set(updateData)
|
||||
.where(eq(resourceRules.ruleId, ruleId))
|
||||
.returning();
|
||||
|
||||
return response(res, {
|
||||
data: updatedRule,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource rule updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,35 +11,8 @@ import { isIpInCidr } from "@server/lib/ip";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { addTargets } from "../newt/targets";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { pickPort } from "./ports";
|
||||
|
||||
// Regular expressions for validation
|
||||
const DOMAIN_REGEX =
|
||||
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||
const IPV4_REGEX =
|
||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
|
||||
|
||||
// Schema for domain names and IP addresses
|
||||
const domainSchema = z
|
||||
.string()
|
||||
.min(1, "Domain cannot be empty")
|
||||
.max(255, "Domain name too long")
|
||||
.refine(
|
||||
(value) => {
|
||||
// Check if it's a valid IP address (v4 or v6)
|
||||
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it's a valid domain name
|
||||
return DOMAIN_REGEX.test(value);
|
||||
},
|
||||
{
|
||||
message: "Invalid domain name or IP address format",
|
||||
path: ["domain"]
|
||||
}
|
||||
);
|
||||
import { pickPort } from "./helpers";
|
||||
import { isTargetValid } from "@server/lib/validators";
|
||||
|
||||
const createTargetParamsSchema = z
|
||||
.object({
|
||||
@@ -52,7 +25,7 @@ const createTargetParamsSchema = z
|
||||
|
||||
const createTargetSchema = z
|
||||
.object({
|
||||
ip: domainSchema,
|
||||
ip: z.string().refine(isTargetValid),
|
||||
method: z.string().optional().nullable(),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
enabled: z.boolean().default(true)
|
||||
|
||||
@@ -10,6 +10,7 @@ import logger from "@server/logger";
|
||||
import { addPeer } from "../gerbil/peers";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { removeTargets } from "../newt/targets";
|
||||
import { getAllowedIps } from "./helpers";
|
||||
|
||||
const deleteTargetSchema = z
|
||||
.object({
|
||||
@@ -80,25 +81,9 @@ export async function deleteTarget(
|
||||
|
||||
if (site.pubKey) {
|
||||
if (site.type == "wireguard") {
|
||||
// TODO: is this all inefficient?
|
||||
// Fetch resources for this site
|
||||
const resourcesRes = await db.query.resources.findMany({
|
||||
where: eq(resources.siteId, site.siteId)
|
||||
});
|
||||
|
||||
// Fetch targets for all resources of this site
|
||||
const targetIps = await Promise.all(
|
||||
resourcesRes.map(async (resource) => {
|
||||
const targetsRes = await db.query.targets.findMany({
|
||||
where: eq(targets.resourceId, resource.resourceId)
|
||||
});
|
||||
return targetsRes.map((target) => `${target.ip}/32`);
|
||||
})
|
||||
);
|
||||
|
||||
await addPeer(site.exitNodeId!, {
|
||||
publicKey: site.pubKey,
|
||||
allowedIps: targetIps.flat()
|
||||
allowedIps: await getAllowedIps(site.siteId)
|
||||
});
|
||||
} else if (site.type == "newt") {
|
||||
// get the newt on the site by querying the newt table for siteId
|
||||
|
||||
@@ -46,3 +46,21 @@ export async function pickPort(siteId: number): Promise<{
|
||||
|
||||
return { internalPort, targetIps };
|
||||
}
|
||||
|
||||
export async function getAllowedIps(siteId: number) {
|
||||
// TODO: is this all inefficient?
|
||||
const resourcesRes = await db.query.resources.findMany({
|
||||
where: eq(resources.siteId, siteId)
|
||||
});
|
||||
|
||||
// Fetch targets for all resources of this site
|
||||
const targetIps = await Promise.all(
|
||||
resourcesRes.map(async (resource) => {
|
||||
const targetsRes = await db.query.targets.findMany({
|
||||
where: eq(targets.resourceId, resource.resourceId)
|
||||
});
|
||||
return targetsRes.map((target) => `${target.ip}/32`);
|
||||
})
|
||||
);
|
||||
return targetIps.flat();
|
||||
}
|
||||
@@ -10,35 +10,8 @@ import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { addPeer } from "../gerbil/peers";
|
||||
import { addTargets } from "../newt/targets";
|
||||
import { pickPort } from "./ports";
|
||||
|
||||
// Regular expressions for validation
|
||||
const DOMAIN_REGEX =
|
||||
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||
const IPV4_REGEX =
|
||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
|
||||
|
||||
// Schema for domain names and IP addresses
|
||||
const domainSchema = z
|
||||
.string()
|
||||
.min(1, "Domain cannot be empty")
|
||||
.max(255, "Domain name too long")
|
||||
.refine(
|
||||
(value) => {
|
||||
// Check if it's a valid IP address (v4 or v6)
|
||||
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it's a valid domain name
|
||||
return DOMAIN_REGEX.test(value);
|
||||
},
|
||||
{
|
||||
message: "Invalid domain name or IP address format",
|
||||
path: ["domain"]
|
||||
}
|
||||
);
|
||||
import { pickPort } from "./helpers";
|
||||
import { isTargetValid } from "@server/lib/validators";
|
||||
|
||||
const updateTargetParamsSchema = z
|
||||
.object({
|
||||
@@ -48,7 +21,7 @@ const updateTargetParamsSchema = z
|
||||
|
||||
const updateTargetBodySchema = z
|
||||
.object({
|
||||
ip: domainSchema.optional(),
|
||||
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()
|
||||
|
||||
@@ -25,6 +25,8 @@ export async function traefikConfigProvider(
|
||||
http: resources.http,
|
||||
proxyPort: resources.proxyPort,
|
||||
protocol: resources.protocol,
|
||||
isBaseDomain: resources.isBaseDomain,
|
||||
domainId: resources.domainId,
|
||||
// Site fields
|
||||
site: {
|
||||
siteId: sites.siteId,
|
||||
@@ -33,8 +35,7 @@ export async function traefikConfigProvider(
|
||||
},
|
||||
// Org fields
|
||||
org: {
|
||||
orgId: orgs.orgId,
|
||||
domain: orgs.domain
|
||||
orgId: orgs.orgId
|
||||
},
|
||||
// Targets as a subquery
|
||||
targets: sql<string>`json_group_array(json_object(
|
||||
@@ -104,25 +105,24 @@ export async function traefikConfigProvider(
|
||||
const site = resource.site;
|
||||
const org = resource.org;
|
||||
|
||||
if (!org.domain) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const routerName = `${resource.resourceId}-router`;
|
||||
const serviceName = `${resource.resourceId}-service`;
|
||||
const fullDomain = `${resource.subdomain}.${org.domain}`;
|
||||
const fullDomain = `${resource.fullDomain}`;
|
||||
|
||||
if (resource.http) {
|
||||
// HTTP configuration remains the same
|
||||
if (!resource.subdomain) {
|
||||
if (!resource.domainId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
targets.filter(
|
||||
(target: Target) => target.internalPort != null
|
||||
).length == 0
|
||||
) {
|
||||
if (!resource.fullDomain) {
|
||||
logger.error(
|
||||
`Resource ${resource.resourceId} has no fullDomain`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// HTTP configuration remains the same
|
||||
if (!resource.subdomain && !resource.isBaseDomain) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -143,9 +143,18 @@ export async function traefikConfigProvider(
|
||||
wildCard = `*.${domainParts.slice(1).join(".")}`;
|
||||
}
|
||||
|
||||
const configDomain = config.getDomain(resource.domainId);
|
||||
|
||||
if (!configDomain) {
|
||||
logger.error(
|
||||
`Failed to get domain from config for resource ${resource.resourceId}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tls = {
|
||||
certResolver: config.getRawConfig().traefik.cert_resolver,
|
||||
...(config.getRawConfig().traefik.prefer_wildcard_cert
|
||||
certResolver: configDomain.cert_resolver,
|
||||
...(configDomain.prefer_wildcard_cert
|
||||
? {
|
||||
domains: [
|
||||
{
|
||||
@@ -156,7 +165,8 @@ export async function traefikConfigProvider(
|
||||
: {})
|
||||
};
|
||||
|
||||
const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || [];
|
||||
const additionalMiddlewares =
|
||||
config.getRawConfig().traefik.additional_middlewares || [];
|
||||
|
||||
config_output.http.routers![routerName] = {
|
||||
entryPoints: [
|
||||
@@ -164,7 +174,10 @@ export async function traefikConfigProvider(
|
||||
? config.getRawConfig().traefik.https_entrypoint
|
||||
: config.getRawConfig().traefik.http_entrypoint
|
||||
],
|
||||
middlewares: [badgerMiddlewareName, ...additionalMiddlewares],
|
||||
middlewares: [
|
||||
badgerMiddlewareName,
|
||||
...additionalMiddlewares
|
||||
],
|
||||
service: serviceName,
|
||||
rule: `Host(\`${fullDomain}\`)`,
|
||||
...(resource.ssl ? { tls } : {})
|
||||
@@ -184,9 +197,31 @@ export async function traefikConfigProvider(
|
||||
config_output.http.services![serviceName] = {
|
||||
loadBalancer: {
|
||||
servers: targets
|
||||
.filter(
|
||||
(target: Target) => target.internalPort != null
|
||||
)
|
||||
.filter((target: Target) => {
|
||||
if (!target.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
site.type === "local" ||
|
||||
site.type === "wireguard"
|
||||
) {
|
||||
if (
|
||||
!target.ip ||
|
||||
!target.port ||
|
||||
!target.method
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
} else if (site.type === "newt") {
|
||||
if (
|
||||
!target.internalPort ||
|
||||
!target.method
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((target: Target) => {
|
||||
if (
|
||||
site.type === "local" ||
|
||||
@@ -213,14 +248,6 @@ export async function traefikConfigProvider(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
targets.filter(
|
||||
(target: Target) => target.internalPort != null
|
||||
).length == 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!config_output[protocol]) {
|
||||
config_output[protocol] = {
|
||||
routers: {},
|
||||
@@ -237,9 +264,24 @@ export async function traefikConfigProvider(
|
||||
config_output[protocol].services[serviceName] = {
|
||||
loadBalancer: {
|
||||
servers: targets
|
||||
.filter(
|
||||
(target: Target) => target.internalPort != null
|
||||
)
|
||||
.filter((target: Target) => {
|
||||
if (!target.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
site.type === "local" ||
|
||||
site.type === "wireguard"
|
||||
) {
|
||||
if (!target.ip || !target.port) {
|
||||
return false;
|
||||
}
|
||||
} else if (site.type === "newt") {
|
||||
if (!target.internalPort) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((target: Target) => {
|
||||
if (
|
||||
site.type === "local" ||
|
||||
@@ -261,9 +303,9 @@ export async function traefikConfigProvider(
|
||||
}
|
||||
return res.status(HttpCode.OK).json(config_output);
|
||||
} catch (e) {
|
||||
logger.error(`Failed to build traefik config: ${e}`);
|
||||
logger.error(`Failed to build Traefik config: ${e}`);
|
||||
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
||||
error: "Failed to build traefik config"
|
||||
error: "Failed to build Traefik config"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
79
server/setup/clearStaleData.ts
Normal file
79
server/setup/clearStaleData.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { db } from "@server/db";
|
||||
import {
|
||||
emailVerificationCodes,
|
||||
newtSessions,
|
||||
passwordResetTokens,
|
||||
resourceAccessToken,
|
||||
resourceOtp,
|
||||
resourceSessions,
|
||||
sessions,
|
||||
userInvites
|
||||
} from "@server/db/schema";
|
||||
import logger from "@server/logger";
|
||||
import { lt } from "drizzle-orm";
|
||||
|
||||
export async function clearStaleData() {
|
||||
try {
|
||||
await db
|
||||
.delete(sessions)
|
||||
.where(lt(sessions.expiresAt, new Date().getTime()));
|
||||
} catch (e) {
|
||||
logger.error("Error clearing expired sessions:", e);
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(newtSessions)
|
||||
.where(lt(newtSessions.expiresAt, new Date().getTime()));
|
||||
} catch (e) {
|
||||
logger.error("Error clearing expired newtSessions:", e);
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(emailVerificationCodes)
|
||||
.where(lt(emailVerificationCodes.expiresAt, new Date().getTime()));
|
||||
} catch (e) {
|
||||
logger.error("Error clearing expired emailVerificationCodes:", e);
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(passwordResetTokens)
|
||||
.where(lt(passwordResetTokens.expiresAt, new Date().getTime()));
|
||||
} catch (e) {
|
||||
logger.error("Error clearing expired passwordResetTokens:", e);
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(userInvites)
|
||||
.where(lt(userInvites.expiresAt, new Date().getTime()));
|
||||
} catch (e) {
|
||||
logger.error("Error clearing expired userInvites:", e);
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(resourceAccessToken)
|
||||
.where(lt(resourceAccessToken.expiresAt, new Date().getTime()));
|
||||
} catch (e) {
|
||||
logger.error("Error clearing expired resourceAccessToken:", e);
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(resourceSessions)
|
||||
.where(lt(resourceSessions.expiresAt, new Date().getTime()));
|
||||
} catch (e) {
|
||||
logger.error("Error clearing expired resourceSessions:", e);
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(resourceOtp)
|
||||
.where(lt(resourceOtp.expiresAt, new Date().getTime()));
|
||||
} catch (e) {
|
||||
logger.error("Error clearing expired resourceOtp:", e);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,103 @@
|
||||
import { db } from "@server/db";
|
||||
import { exitNodes, orgs, resources } from "../db/schema";
|
||||
import { domains, exitNodes, orgDomains, orgs, resources } from "../db/schema";
|
||||
import config from "@server/lib/config";
|
||||
import { eq, ne } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export async function copyInConfig() {
|
||||
const domain = config.getBaseDomain();
|
||||
const endpoint = config.getRawConfig().gerbil.base_endpoint;
|
||||
const listenPort = config.getRawConfig().gerbil.start_port;
|
||||
|
||||
// update the domain on all of the orgs where the domain is not equal to the new domain
|
||||
// TODO: eventually each org could have a unique domain that we do not want to overwrite, so this will be unnecessary
|
||||
await db.update(orgs).set({ domain }).where(ne(orgs.domain, domain));
|
||||
|
||||
// TODO: eventually each exit node could have a different endpoint
|
||||
await db.update(exitNodes).set({ endpoint }).where(ne(exitNodes.endpoint, endpoint));
|
||||
// TODO: eventually each exit node could have a different port
|
||||
await db.update(exitNodes).set({ listenPort }).where(ne(exitNodes.listenPort, listenPort));
|
||||
|
||||
// update all resources fullDomain to use the new domain
|
||||
await db.transaction(async (trx) => {
|
||||
const allResources = await trx.select().from(resources);
|
||||
const rawDomains = config.getRawConfig().domains;
|
||||
|
||||
const configDomains = Object.entries(rawDomains).map(
|
||||
([key, value]) => ({
|
||||
domainId: key,
|
||||
baseDomain: value.base_domain.toLowerCase()
|
||||
})
|
||||
);
|
||||
|
||||
const existingDomains = await trx
|
||||
.select()
|
||||
.from(domains)
|
||||
.where(eq(domains.configManaged, true));
|
||||
const existingDomainKeys = new Set(
|
||||
existingDomains.map((d) => d.domainId)
|
||||
);
|
||||
|
||||
const configDomainKeys = new Set(configDomains.map((d) => d.domainId));
|
||||
for (const existingDomain of existingDomains) {
|
||||
if (!configDomainKeys.has(existingDomain.domainId)) {
|
||||
await trx
|
||||
.delete(domains)
|
||||
.where(eq(domains.domainId, existingDomain.domainId))
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
for (const { domainId, baseDomain } of configDomains) {
|
||||
if (existingDomainKeys.has(domainId)) {
|
||||
await trx
|
||||
.update(domains)
|
||||
.set({ baseDomain })
|
||||
.where(eq(domains.domainId, domainId))
|
||||
.execute();
|
||||
} else {
|
||||
await trx
|
||||
.insert(domains)
|
||||
.values({ domainId, baseDomain, configManaged: true })
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
const allOrgs = await trx.select().from(orgs);
|
||||
|
||||
const existingOrgDomains = await trx.select().from(orgDomains);
|
||||
const existingOrgDomainSet = new Set(
|
||||
existingOrgDomains.map((od) => `${od.orgId}-${od.domainId}`)
|
||||
);
|
||||
|
||||
const newOrgDomains = [];
|
||||
for (const org of allOrgs) {
|
||||
for (const domain of configDomains) {
|
||||
const key = `${org.orgId}-${domain.domainId}`;
|
||||
if (!existingOrgDomainSet.has(key)) {
|
||||
newOrgDomains.push({
|
||||
orgId: org.orgId,
|
||||
domainId: domain.domainId
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newOrgDomains.length > 0) {
|
||||
await trx.insert(orgDomains).values(newOrgDomains).execute();
|
||||
}
|
||||
});
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const allResources = await trx
|
||||
.select()
|
||||
.from(resources)
|
||||
.leftJoin(domains, eq(domains.domainId, resources.domainId));
|
||||
|
||||
for (const { resources: resource, domains: domain } of allResources) {
|
||||
if (!resource || !domain) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!domain.configManaged) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let fullDomain = "";
|
||||
if (resource.isBaseDomain) {
|
||||
fullDomain = domain.baseDomain;
|
||||
} else {
|
||||
fullDomain = `${resource.subdomain}.${domain.baseDomain}`;
|
||||
}
|
||||
|
||||
for (const resource of allResources) {
|
||||
const fullDomain = `${resource.subdomain}.${domain}`;
|
||||
await trx
|
||||
.update(resources)
|
||||
.set({ fullDomain })
|
||||
@@ -31,5 +105,14 @@ export async function copyInConfig() {
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`Updated orgs with new domain (${domain})`);
|
||||
// TODO: eventually each exit node could have a different endpoint
|
||||
await db
|
||||
.update(exitNodes)
|
||||
.set({ endpoint })
|
||||
.where(ne(exitNodes.endpoint, endpoint));
|
||||
// TODO: eventually each exit node could have a different port
|
||||
await db
|
||||
.update(exitNodes)
|
||||
.set({ listenPort })
|
||||
.where(ne(exitNodes.listenPort, listenPort));
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { ensureActions } from "./ensureActions";
|
||||
import { copyInConfig } from "./copyInConfig";
|
||||
import { setupServerAdmin } from "./setupServerAdmin";
|
||||
import logger from "@server/logger";
|
||||
import { clearStaleData } from "./clearStaleData";
|
||||
|
||||
export async function runSetupFunctions() {
|
||||
try {
|
||||
await copyInConfig(); // copy in the config to the db as needed
|
||||
await setupServerAdmin();
|
||||
await ensureActions(); // make sure all of the actions are in the db and the roles
|
||||
await clearStaleData();
|
||||
} catch (error) {
|
||||
logger.error("Error running setup functions:", error);
|
||||
process.exit(1);
|
||||
|
||||
@@ -3,9 +3,9 @@ import db, { exists } from "@server/db";
|
||||
import path from "path";
|
||||
import semver from "semver";
|
||||
import { versionMigrations } from "@server/db/schema";
|
||||
import { desc } from "drizzle-orm";
|
||||
import { __DIRNAME } from "@server/lib/consts";
|
||||
import { loadAppVersion } from "@server/lib/loadAppVersion";
|
||||
import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts";
|
||||
import { SqliteError } from "better-sqlite3";
|
||||
import fs from "fs";
|
||||
import m1 from "./scripts/1.0.0-beta1";
|
||||
import m2 from "./scripts/1.0.0-beta2";
|
||||
import m3 from "./scripts/1.0.0-beta3";
|
||||
@@ -13,6 +13,9 @@ import m4 from "./scripts/1.0.0-beta5";
|
||||
import m5 from "./scripts/1.0.0-beta6";
|
||||
import m6 from "./scripts/1.0.0-beta9";
|
||||
import m7 from "./scripts/1.0.0-beta10";
|
||||
import m8 from "./scripts/1.0.0-beta12";
|
||||
import m13 from "./scripts/1.0.0-beta13";
|
||||
import m15 from "./scripts/1.0.0-beta15";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
@@ -25,19 +28,47 @@ const migrations = [
|
||||
{ version: "1.0.0-beta.5", run: m4 },
|
||||
{ version: "1.0.0-beta.6", run: m5 },
|
||||
{ version: "1.0.0-beta.9", run: m6 },
|
||||
{ version: "1.0.0-beta.10", run: m7 }
|
||||
{ version: "1.0.0-beta.10", run: m7 },
|
||||
{ version: "1.0.0-beta.12", run: m8 },
|
||||
{ version: "1.0.0-beta.13", run: m13 },
|
||||
{ version: "1.0.0-beta.15", run: m15 }
|
||||
// Add new migrations here as they are created
|
||||
] as const;
|
||||
|
||||
// Run the migrations
|
||||
await runMigrations();
|
||||
await run();
|
||||
|
||||
async function run() {
|
||||
// backup the database
|
||||
backupDb();
|
||||
|
||||
// run the migrations
|
||||
await runMigrations();
|
||||
}
|
||||
|
||||
function backupDb() {
|
||||
// make dir config/db/backups
|
||||
const appPath = APP_PATH;
|
||||
const dbDir = path.join(appPath, "db");
|
||||
|
||||
const backupsDir = path.join(dbDir, "backups");
|
||||
|
||||
// check if the backups directory exists and create it if it doesn't
|
||||
if (!fs.existsSync(backupsDir)) {
|
||||
fs.mkdirSync(backupsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// copy the db.sqlite file to backups
|
||||
// add the date to the filename
|
||||
const date = new Date();
|
||||
const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`;
|
||||
const dbPath = path.join(dbDir, "db.sqlite");
|
||||
const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`);
|
||||
fs.copyFileSync(dbPath, backupPath);
|
||||
}
|
||||
|
||||
export async function runMigrations() {
|
||||
try {
|
||||
const appVersion = loadAppVersion();
|
||||
if (!appVersion) {
|
||||
throw new Error("APP_VERSION is not set in the environment");
|
||||
}
|
||||
const appVersion = APP_VERSION;
|
||||
|
||||
if (exists) {
|
||||
await executeScripts();
|
||||
@@ -53,38 +84,44 @@ export async function runMigrations() {
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(versionMigrations)
|
||||
.values({
|
||||
version: appVersion,
|
||||
executedAt: Date.now()
|
||||
})
|
||||
.execute();
|
||||
.insert(versionMigrations)
|
||||
.values({
|
||||
version: appVersion,
|
||||
executedAt: Date.now()
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error running migrations:", e);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * 60 * 60 * 24 * 1));
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 1000 * 60 * 60 * 24 * 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeScripts() {
|
||||
try {
|
||||
// Get the last executed version from the database
|
||||
const lastExecuted = await db
|
||||
.select()
|
||||
.from(versionMigrations)
|
||||
.orderBy(desc(versionMigrations.version))
|
||||
.limit(1);
|
||||
|
||||
const startVersion = lastExecuted[0]?.version ?? "0.0.0";
|
||||
console.log(`Starting migrations from version ${startVersion}`);
|
||||
const lastExecuted = await db.select().from(versionMigrations);
|
||||
|
||||
// Filter and sort migrations
|
||||
const pendingMigrations = migrations
|
||||
.filter((migration) => semver.gt(migration.version, startVersion))
|
||||
.sort((a, b) => semver.compare(a.version, b.version));
|
||||
const pendingMigrations = lastExecuted
|
||||
.map((m) => m)
|
||||
.sort((a, b) => semver.compare(b.version, a.version));
|
||||
const startVersion = pendingMigrations[0]?.version ?? "0.0.0";
|
||||
console.log(`Starting migrations from version ${startVersion}`);
|
||||
|
||||
const migrationsToRun = migrations.filter((migration) =>
|
||||
semver.gt(migration.version, startVersion)
|
||||
);
|
||||
|
||||
console.log(
|
||||
"Migrations to run:",
|
||||
migrationsToRun.map((m) => m.version).join(", ")
|
||||
);
|
||||
|
||||
// Run migrations in order
|
||||
for (const migration of pendingMigrations) {
|
||||
for (const migration of migrationsToRun) {
|
||||
console.log(`Running migration ${migration.version}`);
|
||||
|
||||
try {
|
||||
@@ -102,12 +139,19 @@ async function executeScripts() {
|
||||
console.log(
|
||||
`Successfully completed migration ${migration.version}`
|
||||
);
|
||||
} catch (error) {
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof SqliteError &&
|
||||
e.code === "SQLITE_CONSTRAINT_UNIQUE"
|
||||
) {
|
||||
console.error("Migration has already run! Skipping...");
|
||||
continue;
|
||||
}
|
||||
console.error(
|
||||
`Failed to run migration ${migration.version}:`,
|
||||
error
|
||||
e
|
||||
);
|
||||
throw error; // Re-throw to stop migration process
|
||||
throw e; // Re-throw to stop migration process
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
62
server/setup/scripts/1.0.0-beta12.ts
Normal file
62
server/setup/scripts/1.0.0-beta12.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import db from "@server/db";
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import { sql } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
export default async function migration() {
|
||||
console.log("Running setup script 1.0.0-beta.12...");
|
||||
|
||||
try {
|
||||
// Determine which config file exists
|
||||
const filePaths = [configFilePath1, configFilePath2];
|
||||
let filePath = "";
|
||||
for (const path of filePaths) {
|
||||
if (fs.existsSync(path)) {
|
||||
filePath = path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
throw new Error(
|
||||
`No config file found (expected config.yml or config.yaml).`
|
||||
);
|
||||
}
|
||||
|
||||
// Read and parse the YAML file
|
||||
let rawConfig: any;
|
||||
const fileContents = fs.readFileSync(filePath, "utf8");
|
||||
rawConfig = yaml.load(fileContents);
|
||||
|
||||
if (!rawConfig.flags) {
|
||||
rawConfig.flags = {};
|
||||
}
|
||||
|
||||
rawConfig.flags.allow_base_domain_resources = true;
|
||||
|
||||
// Write the updated YAML back to the file
|
||||
const updatedYaml = yaml.dump(rawConfig);
|
||||
fs.writeFileSync(filePath, updatedYaml, "utf8");
|
||||
|
||||
console.log(`Added new config option: allow_base_domain_resources`);
|
||||
} catch (e) {
|
||||
console.log(
|
||||
`Unable to add new config option: allow_base_domain_resources. This is not critical.`
|
||||
);
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
try {
|
||||
db.transaction((trx) => {
|
||||
trx.run(sql`ALTER TABLE 'resources' ADD 'isBaseDomain' integer;`);
|
||||
});
|
||||
|
||||
console.log(`Added new column: isBaseDomain`);
|
||||
} catch (e) {
|
||||
console.log("Unable to add new column: isBaseDomain");
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log("Done.");
|
||||
}
|
||||
33
server/setup/scripts/1.0.0-beta13.ts
Normal file
33
server/setup/scripts/1.0.0-beta13.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import db from "@server/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
const version = "1.0.0-beta.13";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
try {
|
||||
db.transaction((trx) => {
|
||||
trx.run(sql`CREATE TABLE resourceRules (
|
||||
ruleId integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
resourceId integer NOT NULL,
|
||||
priority integer NOT NULL,
|
||||
enabled integer DEFAULT true NOT NULL,
|
||||
action text NOT NULL,
|
||||
match text NOT NULL,
|
||||
value text NOT NULL,
|
||||
FOREIGN KEY (resourceId) REFERENCES resources(resourceId) ON UPDATE no action ON DELETE cascade
|
||||
);`);
|
||||
trx.run(
|
||||
sql`ALTER TABLE resources ADD applyRules integer DEFAULT false NOT NULL;`
|
||||
);
|
||||
});
|
||||
|
||||
console.log(`Added new table and column: resourceRules, applyRules`);
|
||||
} catch (e) {
|
||||
console.log("Unable to add new table and column: resourceRules, applyRules");
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log(`${version} migration complete`);
|
||||
}
|
||||
129
server/setup/scripts/1.0.0-beta15.ts
Normal file
129
server/setup/scripts/1.0.0-beta15.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import db from "@server/db";
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { domains, orgDomains, resources } from "@server/db/schema";
|
||||
|
||||
const version = "1.0.0-beta.15";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
let domain = "";
|
||||
|
||||
try {
|
||||
// Determine which config file exists
|
||||
const filePaths = [configFilePath1, configFilePath2];
|
||||
let filePath = "";
|
||||
for (const path of filePaths) {
|
||||
if (fs.existsSync(path)) {
|
||||
filePath = path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
throw new Error(
|
||||
`No config file found (expected config.yml or config.yaml).`
|
||||
);
|
||||
}
|
||||
|
||||
// Read and parse the YAML file
|
||||
let rawConfig: any;
|
||||
const fileContents = fs.readFileSync(filePath, "utf8");
|
||||
rawConfig = yaml.load(fileContents);
|
||||
|
||||
const baseDomain = rawConfig.app.base_domain;
|
||||
const certResolver = rawConfig.traefik.cert_resolver;
|
||||
const preferWildcardCert = rawConfig.traefik.prefer_wildcard_cert;
|
||||
|
||||
delete rawConfig.traefik.prefer_wildcard_cert;
|
||||
delete rawConfig.traefik.cert_resolver;
|
||||
delete rawConfig.app.base_domain;
|
||||
|
||||
rawConfig.domains = {
|
||||
domain1: {
|
||||
base_domain: baseDomain
|
||||
}
|
||||
};
|
||||
|
||||
if (certResolver) {
|
||||
rawConfig.domains.domain1.cert_resolver = certResolver;
|
||||
}
|
||||
|
||||
if (preferWildcardCert) {
|
||||
rawConfig.domains.domain1.prefer_wildcard_cert = preferWildcardCert;
|
||||
}
|
||||
|
||||
// Write the updated YAML back to the file
|
||||
const updatedYaml = yaml.dump(rawConfig);
|
||||
fs.writeFileSync(filePath, updatedYaml, "utf8");
|
||||
|
||||
domain = baseDomain;
|
||||
|
||||
console.log(`Moved base_domain to new domains section`);
|
||||
} catch (e) {
|
||||
console.log(
|
||||
`Unable to migrate config file and move base_domain to domains. Error: ${e}`
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
db.transaction((trx) => {
|
||||
trx.run(sql`CREATE TABLE 'domains' (
|
||||
'domainId' text PRIMARY KEY NOT NULL,
|
||||
'baseDomain' text NOT NULL,
|
||||
'configManaged' integer DEFAULT false NOT NULL
|
||||
);`);
|
||||
|
||||
trx.run(sql`CREATE TABLE 'orgDomains' (
|
||||
'orgId' text NOT NULL,
|
||||
'domainId' text NOT NULL,
|
||||
FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY ('domainId') REFERENCES 'domains'('domainId') ON UPDATE no action ON DELETE cascade
|
||||
);`);
|
||||
|
||||
trx.run(
|
||||
sql`ALTER TABLE 'resources' ADD 'domainId' text REFERENCES domains(domainId);`
|
||||
);
|
||||
trx.run(sql`ALTER TABLE 'orgs' DROP COLUMN 'domain';`);
|
||||
});
|
||||
|
||||
console.log(`Migrated database schema`);
|
||||
} catch (e) {
|
||||
console.log("Unable to migrate database schema");
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.insert(domains)
|
||||
.values({
|
||||
domainId: "domain1",
|
||||
baseDomain: domain,
|
||||
configManaged: true
|
||||
})
|
||||
.execute();
|
||||
await trx.update(resources).set({ domainId: "domain1" });
|
||||
const existingOrgDomains = await trx.select().from(orgDomains);
|
||||
for (const orgDomain of existingOrgDomains) {
|
||||
await trx
|
||||
.insert(orgDomains)
|
||||
.values({ orgId: orgDomain.orgId, domainId: "domain1" })
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Updated resources table with new domainId`);
|
||||
} catch (e) {
|
||||
console.log(
|
||||
`Unable to update resources table with new domainId. Error: ${e}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${version} migration complete`);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useState } from "react";
|
||||
@@ -48,7 +48,6 @@ export default function CreateRoleForm({
|
||||
setOpen,
|
||||
afterCreate,
|
||||
}: CreateRoleFormProps) {
|
||||
const { toast } = useToast();
|
||||
const { org } = useOrgContext();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -137,7 +136,6 @@ export default function CreateRoleForm({
|
||||
<FormLabel>Role Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter name for the role"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -153,7 +151,6 @@ export default function CreateRoleForm({
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Describe the role"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@app/components/ui/form";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -56,7 +56,6 @@ export default function DeleteRoleForm({
|
||||
setOpen,
|
||||
afterDelete,
|
||||
}: CreateRoleFormProps) {
|
||||
const { toast } = useToast();
|
||||
const { org } = useOrgContext();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel,
|
||||
getFilteredRowModel
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Table,
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableRow
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useState } from "react";
|
||||
@@ -35,7 +35,7 @@ interface DataTableProps<TData, TValue> {
|
||||
export function RolesDataTable<TData, TValue>({
|
||||
addRole,
|
||||
columns,
|
||||
data,
|
||||
data
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
@@ -49,14 +49,16 @@ export function RolesDataTable<TData, TValue>({
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 20,
|
||||
pageIndex: 0
|
||||
}
|
||||
},
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
pagination: {
|
||||
pageSize: 100,
|
||||
pageIndex: 0,
|
||||
},
|
||||
},
|
||||
columnFilters
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -102,7 +104,7 @@ export function RolesDataTable<TData, TValue>({
|
||||
: flexRender(
|
||||
header.column.columnDef
|
||||
.header,
|
||||
header.getContext(),
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
@@ -123,7 +125,7 @@ export function RolesDataTable<TData, TValue>({
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { RolesDataTable } from "./RolesDataTable";
|
||||
import { Role } from "@server/db/schema";
|
||||
import CreateRoleForm from "./CreateRoleForm";
|
||||
@@ -37,7 +37,6 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const { org } = useOrgContext();
|
||||
const { toast } = useToast();
|
||||
|
||||
const columns: ColumnDef<RoleRow>[] = [
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { InviteUserBody, InviteUserResponse } from "@server/routers/user";
|
||||
import { AxiosResponse } from "axios";
|
||||
@@ -54,7 +54,6 @@ const formSchema = z.object({
|
||||
});
|
||||
|
||||
export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
|
||||
const { toast } = useToast();
|
||||
const { org } = useOrgContext();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
@@ -196,7 +195,6 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter an email"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel,
|
||||
getFilteredRowModel
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Table,
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableRow
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useState } from "react";
|
||||
@@ -35,7 +35,7 @@ interface DataTableProps<TData, TValue> {
|
||||
export function UsersDataTable<TData, TValue>({
|
||||
inviteUser,
|
||||
columns,
|
||||
data,
|
||||
data
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
@@ -49,14 +49,16 @@ export function UsersDataTable<TData, TValue>({
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 20,
|
||||
pageIndex: 0
|
||||
}
|
||||
},
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
pagination: {
|
||||
pageSize: 100,
|
||||
pageIndex: 0,
|
||||
},
|
||||
},
|
||||
columnFilters
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -102,7 +104,7 @@ export function UsersDataTable<TData, TValue>({
|
||||
: flexRender(
|
||||
header.column.columnDef
|
||||
.header,
|
||||
header.getContext(),
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
@@ -123,7 +125,7 @@ export function UsersDataTable<TData, TValue>({
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useState } from "react";
|
||||
import InviteUserForm from "./InviteUserForm";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
@@ -47,7 +47,6 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
|
||||
const { user, updateUser } = useUserContext();
|
||||
const { org } = useOrgContext();
|
||||
const { toast } = useToast();
|
||||
|
||||
const columns: ColumnDef<UserRow>[] = [
|
||||
{
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { InviteUserResponse } from "@server/routers/user";
|
||||
import { AxiosResponse } from "axios";
|
||||
@@ -47,7 +47,6 @@ const formSchema = z.object({
|
||||
});
|
||||
|
||||
export default function AccessControlsPage() {
|
||||
const { toast } = useToast();
|
||||
const { orgUser: user } = userOrgUserContext();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
@@ -73,7 +73,6 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
|
||||
|
||||
<SidebarSettings
|
||||
sidebarNavItems={sidebarNavItems}
|
||||
limitWidth={true}
|
||||
>
|
||||
{children}
|
||||
</SidebarSettings>
|
||||
|
||||
@@ -4,7 +4,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Form,
|
||||
@@ -56,7 +56,6 @@ export default function GeneralPage() {
|
||||
const { orgUser } = userOrgUserContext();
|
||||
const router = useRouter();
|
||||
const { org } = useOrgContext();
|
||||
const { toast } = useToast();
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const [loadingDelete, setLoadingDelete] = useState(false);
|
||||
@@ -211,11 +210,11 @@ export default function GeneralPage() {
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is the display name of the
|
||||
org
|
||||
organization.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -239,7 +238,6 @@ export default function GeneralPage() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
Danger Zone
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -59,19 +59,24 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
||||
import { subdomainSchema } from "@server/lib/schemas";
|
||||
import Link from "next/link";
|
||||
import { SquareArrowOutUpRight } from "lucide-react";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import { ListDomainsResponse } from "@server/routers/domain";
|
||||
|
||||
const createResourceFormSchema = z
|
||||
.object({
|
||||
subdomain: z.string().optional(),
|
||||
domainId: z.string().min(1).optional(),
|
||||
name: z.string().min(1).max(255),
|
||||
siteId: z.number(),
|
||||
http: z.boolean(),
|
||||
protocol: z.string(),
|
||||
proxyPort: z.number().optional()
|
||||
proxyPort: z.number().optional(),
|
||||
isBaseDomain: z.boolean().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -92,7 +97,7 @@ const createResourceFormSchema = z
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.http) {
|
||||
if (data.http && !data.isBaseDomain) {
|
||||
return subdomainSchema.safeParse(data.subdomain).success;
|
||||
}
|
||||
return true;
|
||||
@@ -114,8 +119,7 @@ export default function CreateResourceForm({
|
||||
open,
|
||||
setOpen
|
||||
}: CreateResourceFormProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [formKey, setFormKey] = useState(0);
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -128,54 +132,106 @@ export default function CreateResourceForm({
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
||||
const [domainSuffix, setDomainSuffix] = useState<string>(org.org.domain);
|
||||
|
||||
const [baseDomains, setBaseDomains] = useState<
|
||||
{ domainId: string; baseDomain: string }[]
|
||||
>([]);
|
||||
const [showSnippets, setShowSnippets] = useState(false);
|
||||
|
||||
const [resourceId, setResourceId] = useState<number | null>(null);
|
||||
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
|
||||
"subdomain"
|
||||
);
|
||||
|
||||
const form = useForm<CreateResourceFormValues>({
|
||||
resolver: zodResolver(createResourceFormSchema),
|
||||
defaultValues: {
|
||||
subdomain: "",
|
||||
name: "My Resource",
|
||||
domainId: "",
|
||||
name: "",
|
||||
http: true,
|
||||
protocol: "tcp"
|
||||
}
|
||||
});
|
||||
|
||||
function reset() {
|
||||
form.reset();
|
||||
setSites([]);
|
||||
setShowSnippets(false);
|
||||
setResourceId(null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchSites = async () => {
|
||||
const res = await api.get<AxiosResponse<ListSitesResponse>>(
|
||||
`/org/${orgId}/sites/`
|
||||
);
|
||||
setSites(res.data.data.sites);
|
||||
reset();
|
||||
|
||||
if (res.data.data.sites.length > 0) {
|
||||
form.setValue("siteId", res.data.data.sites[0].siteId);
|
||||
const fetchSites = async () => {
|
||||
const res = await api
|
||||
.get<AxiosResponse<ListSitesResponse>>(`/org/${orgId}/sites/`)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error fetching sites",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred when fetching the sites"
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res?.status === 200) {
|
||||
setSites(res.data.data.sites);
|
||||
|
||||
if (res.data.data.sites.length > 0) {
|
||||
form.setValue("siteId", res.data.data.sites[0].siteId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDomains = async () => {
|
||||
const res = await api
|
||||
.get<
|
||||
AxiosResponse<ListDomainsResponse>
|
||||
>(`/org/${orgId}/domains/`)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error fetching domains",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred when fetching the domains"
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res?.status === 200) {
|
||||
const domains = res.data.data.domains;
|
||||
setBaseDomains(domains);
|
||||
if (domains.length) {
|
||||
form.setValue("domainId", domains[0].domainId);
|
||||
setFormKey((k) => k + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchSites();
|
||||
fetchDomains();
|
||||
}, [open]);
|
||||
|
||||
async function onSubmit(data: CreateResourceFormValues) {
|
||||
console.log(data);
|
||||
|
||||
const res = await api
|
||||
.put<AxiosResponse<Resource>>(
|
||||
`/org/${orgId}/site/${data.siteId}/resource/`,
|
||||
{
|
||||
name: data.name,
|
||||
subdomain: data.http ? data.subdomain : undefined,
|
||||
domainId: data.http ? data.domainId : undefined,
|
||||
http: data.http,
|
||||
protocol: data.protocol,
|
||||
proxyPort: data.http ? undefined : data.proxyPort,
|
||||
siteId: data.siteId
|
||||
siteId: data.siteId,
|
||||
isBaseDomain: data.http ? undefined : data.isBaseDomain
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
@@ -194,16 +250,16 @@ export default function CreateResourceForm({
|
||||
setResourceId(id);
|
||||
|
||||
if (data.http) {
|
||||
goToResource();
|
||||
goToResource(id);
|
||||
} else {
|
||||
setShowSnippets(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function goToResource() {
|
||||
function goToResource(id?: number) {
|
||||
// navigate to the resource page
|
||||
router.push(`/${orgId}/settings/resources/${resourceId}`);
|
||||
router.push(`/${orgId}/settings/resources/${id || resourceId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -227,34 +283,12 @@ export default function CreateResourceForm({
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
{!showSnippets && (
|
||||
<Form {...form}>
|
||||
<Form {...form} key={formKey}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="create-resource-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Your name"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is the name that will
|
||||
be displayed for this
|
||||
resource.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!env.flags.allowRawResources || (
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -268,7 +302,8 @@ export default function CreateResourceForm({
|
||||
<FormDescription>
|
||||
Toggle if this is an
|
||||
HTTP resource or a
|
||||
raw TCP/UDP resource
|
||||
raw TCP/UDP
|
||||
resource.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
@@ -286,43 +321,194 @@ export default function CreateResourceForm({
|
||||
/>
|
||||
)}
|
||||
|
||||
{form.watch("http") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subdomain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Subdomain
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<CustomDomainInput
|
||||
value={
|
||||
field.value ??
|
||||
""
|
||||
}
|
||||
domainSuffix={
|
||||
domainSuffix
|
||||
}
|
||||
placeholder="Enter subdomain"
|
||||
onChange={(value) =>
|
||||
form.setValue(
|
||||
"subdomain",
|
||||
value
|
||||
)
|
||||
}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is display name for the
|
||||
resource.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("http") &&
|
||||
env.flags.allowBaseDomainResources && (
|
||||
<div>
|
||||
<RadioGroup
|
||||
className="flex space-x-4"
|
||||
defaultValue={domainType}
|
||||
onValueChange={(val) => {
|
||||
setDomainType(
|
||||
val as any
|
||||
);
|
||||
form.setValue(
|
||||
"isBaseDomain",
|
||||
val === "basedomain"
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="subdomain"
|
||||
id="r1"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is the fully
|
||||
qualified domain name
|
||||
that will be used to
|
||||
access the resource.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
<Label htmlFor="r1">
|
||||
Subdomain
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="basedomain"
|
||||
id="r2"
|
||||
/>
|
||||
<Label htmlFor="r2">
|
||||
Base Domain
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.watch("http") && (
|
||||
<>
|
||||
{domainType === "subdomain" ? (
|
||||
<div className="w-fill space-y-2">
|
||||
{!env.flags
|
||||
.allowBaseDomainResources && (
|
||||
<FormLabel>
|
||||
Subdomain
|
||||
</FormLabel>
|
||||
)}
|
||||
<div className="flex">
|
||||
<div className="w-full mr-1">
|
||||
<FormField
|
||||
control={
|
||||
form.control
|
||||
}
|
||||
name="subdomain"
|
||||
render={({
|
||||
field
|
||||
}) => (
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="text-right"
|
||||
placeholder="Enter subdomain"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-1/2">
|
||||
<FormField
|
||||
control={
|
||||
form.control
|
||||
}
|
||||
name="domainId"
|
||||
render={({
|
||||
field
|
||||
}) => (
|
||||
<FormItem>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
value={
|
||||
field.value
|
||||
}
|
||||
defaultValue={
|
||||
field.value
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{baseDomains.map(
|
||||
(
|
||||
option
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
option.domainId
|
||||
}
|
||||
value={
|
||||
option.domainId
|
||||
}
|
||||
>
|
||||
.
|
||||
{
|
||||
option.baseDomain
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domainId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
defaultValue={
|
||||
field.value
|
||||
}
|
||||
{...field}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{baseDomains.map(
|
||||
(
|
||||
option
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
option.domainId
|
||||
}
|
||||
value={
|
||||
option.domainId
|
||||
}
|
||||
>
|
||||
{
|
||||
option.baseDomain
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!form.watch("http") && (
|
||||
@@ -370,11 +556,11 @@ export default function CreateResourceForm({
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
The protocol to use
|
||||
for the resource
|
||||
for the resource.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -389,7 +575,6 @@ export default function CreateResourceForm({
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Enter port number"
|
||||
value={
|
||||
field.value ??
|
||||
""
|
||||
@@ -408,13 +593,13 @@ export default function CreateResourceForm({
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
The port number to
|
||||
proxy requests to
|
||||
(required for
|
||||
non-HTTP resources)
|
||||
non-HTTP resources).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -454,7 +639,7 @@ export default function CreateResourceForm({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search site..." />
|
||||
<CommandInput placeholder="Search site" />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No site
|
||||
@@ -466,9 +651,7 @@ export default function CreateResourceForm({
|
||||
site
|
||||
) => (
|
||||
<CommandItem
|
||||
value={
|
||||
site.niceId
|
||||
}
|
||||
value={`${site.siteId}:${site.name}:${site.niceId}`}
|
||||
key={
|
||||
site.siteId
|
||||
}
|
||||
@@ -499,11 +682,12 @@ export default function CreateResourceForm({
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormDescription>
|
||||
This is the site that will
|
||||
be used in the dashboard.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This site will provide
|
||||
connectivity to the
|
||||
resource.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -562,21 +746,25 @@ export default function CreateResourceForm({
|
||||
)}
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
{!showSnippets && <Button
|
||||
type="submit"
|
||||
form="create-resource-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Create Resource
|
||||
</Button>}
|
||||
{!showSnippets && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-resource-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Create Resource
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showSnippets && <Button
|
||||
loading={loading}
|
||||
onClick={() => goToResource()}
|
||||
>
|
||||
Go to Resource
|
||||
</Button>}
|
||||
{showSnippets && (
|
||||
<Button
|
||||
loading={loading}
|
||||
onClick={() => goToResource()}
|
||||
>
|
||||
Go to Resource
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel,
|
||||
getFilteredRowModel
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableRow
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useState } from "react";
|
||||
@@ -36,7 +36,7 @@ interface ResourcesDataTableProps<TData, TValue> {
|
||||
export function ResourcesDataTable<TData, TValue>({
|
||||
addResource,
|
||||
columns,
|
||||
data,
|
||||
data
|
||||
}: ResourcesDataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
@@ -50,14 +50,16 @@ export function ResourcesDataTable<TData, TValue>({
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 20,
|
||||
pageIndex: 0
|
||||
}
|
||||
},
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
pagination: {
|
||||
pageSize: 100,
|
||||
pageIndex: 0,
|
||||
},
|
||||
},
|
||||
columnFilters
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -103,7 +105,7 @@ export function ResourcesDataTable<TData, TValue>({
|
||||
: flexRender(
|
||||
header.column.columnDef
|
||||
.header,
|
||||
header.getContext(),
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
@@ -124,7 +126,7 @@ export function ResourcesDataTable<TData, TValue>({
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
|
||||
@@ -42,7 +42,7 @@ export const ResourcesSplashCard = () => {
|
||||
Resources
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
Resources are proxies to applications running on your private network. Create a resource for any HTTP or HTTPS app on your private network.
|
||||
Resources are proxies to applications running on your private network. Create a resource for any HTTP/HTTPS or raw TCP/UDP service on your private network.
|
||||
Each resource must be connected to a site to enable private, secure connectivity through an encrypted WireGuard tunnel.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { set } from "zod";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
@@ -38,7 +38,7 @@ export type ResourceRow = {
|
||||
domain: string;
|
||||
site: string;
|
||||
siteId: string;
|
||||
hasAuth: boolean;
|
||||
authState: string;
|
||||
http: boolean;
|
||||
protocol: string;
|
||||
proxyPort: number | null;
|
||||
@@ -52,8 +52,6 @@ type ResourcesTableProps = {
|
||||
export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
@@ -165,9 +163,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
header: "Protocol",
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<span>{resourceRow.protocol.toUpperCase()}</span>
|
||||
);
|
||||
return <span>{resourceRow.protocol.toUpperCase()}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -177,17 +173,23 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<div>
|
||||
{!resourceRow.http ? (
|
||||
<CopyToClipboard text={resourceRow.proxyPort!.toString()} isLink={false} />
|
||||
) : (
|
||||
<CopyToClipboard text={resourceRow.domain} isLink={true} />
|
||||
)}
|
||||
{!resourceRow.http ? (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.proxyPort!.toString()}
|
||||
isLink={false}
|
||||
/>
|
||||
) : (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.domain}
|
||||
isLink={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "hasAuth",
|
||||
accessorKey: "authState",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
@@ -205,23 +207,19 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<div>
|
||||
|
||||
|
||||
{!resourceRow.http ? (
|
||||
{resourceRow.authState === "protected" ? (
|
||||
<span className="text-green-500 flex items-center space-x-2">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
<span>Protected</span>
|
||||
</span>
|
||||
) : resourceRow.authState === "not_protected" ? (
|
||||
<span className="text-yellow-500 flex items-center space-x-2">
|
||||
<ShieldOff className="w-4 h-4" />
|
||||
<span>Not Protected</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>--</span>
|
||||
) :
|
||||
resourceRow.hasAuth ? (
|
||||
<span className="text-green-500 flex items-center space-x-2">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
<span>Protected</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-yellow-500 flex items-center space-x-2">
|
||||
<ShieldOff className="w-4 h-4" />
|
||||
<span>Not Protected</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,27 +2,68 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface DomainOption {
|
||||
baseDomain: string;
|
||||
domainId: string;
|
||||
}
|
||||
|
||||
interface CustomDomainInputProps {
|
||||
domainSuffix: string;
|
||||
domainOptions: DomainOption[];
|
||||
selectedDomainId?: string;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
onChange?: (value: string, selectedDomainId: string) => void;
|
||||
}
|
||||
|
||||
export default function CustomDomainInput({
|
||||
domainSuffix,
|
||||
placeholder = "Enter subdomain",
|
||||
domainOptions,
|
||||
selectedDomainId,
|
||||
placeholder = "Subdomain",
|
||||
value: defaultValue,
|
||||
onChange,
|
||||
onChange
|
||||
}: CustomDomainInputProps) {
|
||||
const [value, setValue] = React.useState(defaultValue);
|
||||
const [selectedDomain, setSelectedDomain] = React.useState<DomainOption>();
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
React.useEffect(() => {
|
||||
if (domainOptions.length) {
|
||||
if (selectedDomainId) {
|
||||
const selectedDomainOption = domainOptions.find(
|
||||
(option) => option.domainId === selectedDomainId
|
||||
);
|
||||
setSelectedDomain(selectedDomainOption || domainOptions[0]);
|
||||
} else {
|
||||
setSelectedDomain(domainOptions[0]);
|
||||
}
|
||||
}
|
||||
}, [domainOptions]);
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!selectedDomain) {
|
||||
return;
|
||||
}
|
||||
const newValue = event.target.value;
|
||||
setValue(newValue);
|
||||
if (onChange) {
|
||||
onChange(newValue);
|
||||
onChange(newValue, selectedDomain.domainId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDomainChange = (domainId: string) => {
|
||||
const newSelectedDomain =
|
||||
domainOptions.find((option) => option.domainId === domainId) ||
|
||||
domainOptions[0];
|
||||
setSelectedDomain(newSelectedDomain);
|
||||
if (onChange) {
|
||||
onChange(value, newSelectedDomain.domainId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -33,12 +74,28 @@ export default function CustomDomainInput({
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
className="rounded-r-none flex-grow"
|
||||
onChange={handleInputChange}
|
||||
className="w-1/2 mr-1 text-right"
|
||||
/>
|
||||
<div className="inline-flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-muted-foreground">
|
||||
<span className="text-sm">.{domainSuffix}</span>
|
||||
</div>
|
||||
<Select
|
||||
onValueChange={handleDomainChange}
|
||||
value={selectedDomain?.domainId}
|
||||
defaultValue={selectedDomain?.domainId}
|
||||
>
|
||||
<SelectTrigger className="w-1/2 pr-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{domainOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.domainId}
|
||||
value={option.domainId}
|
||||
>
|
||||
.{option.baseDomain}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
InfoIcon,
|
||||
ShieldCheck,
|
||||
ShieldOff
|
||||
} from "lucide-react";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
@@ -21,14 +15,9 @@ import {
|
||||
type ResourceInfoBoxType = {};
|
||||
|
||||
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const { org } = useOrgContext();
|
||||
const { resource, authInfo } = useResourceContext();
|
||||
|
||||
const fullUrl = `${resource.ssl ? "https" : "http"}://${
|
||||
resource.subdomain
|
||||
}.${org.org.domain}`;
|
||||
let fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
@@ -53,7 +42,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
||||
<span>
|
||||
This resource is protected with
|
||||
at least one auth method.
|
||||
at least one authentication method.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -82,7 +71,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Protocol</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span>{resource.protocol.toUpperCase()}</span>
|
||||
<span>
|
||||
{resource.protocol.toUpperCase()}
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<Separator orientation="vertical" />
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -55,8 +55,6 @@ export default function SetResourcePasswordForm({
|
||||
resourceId,
|
||||
onSetPassword,
|
||||
}: SetPasswordFormProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -138,17 +136,16 @@ export default function SetResourcePasswordForm({
|
||||
<Input
|
||||
autoComplete="off"
|
||||
type="password"
|
||||
placeholder="Your secure password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
Users will be able to access
|
||||
this resource by entering this
|
||||
password. It must be at least 4
|
||||
characters long.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -60,8 +60,6 @@ export default function SetResourcePincodeForm({
|
||||
resourceId,
|
||||
onSetPincode,
|
||||
}: SetPincodeFormProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
@@ -169,13 +167,13 @@ export default function SetResourcePincodeForm({
|
||||
</InputOTP>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
Users will be able to access
|
||||
this resource by entering this
|
||||
PIN code. It must be at least 6
|
||||
digits long.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -2,20 +2,18 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ListRolesResponse } from "@server/routers/role";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import {
|
||||
GetResourceAuthInfoResponse,
|
||||
GetResourceWhitelistResponse,
|
||||
ListResourceRolesResponse,
|
||||
ListResourceUsersResponse
|
||||
} from "@server/routers/resource";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { set, z } from "zod";
|
||||
import { Tag } from "emblor";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
@@ -27,12 +25,8 @@ import {
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { TagInput } from "emblor";
|
||||
// import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { ListUsersResponse } from "@server/routers/user";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import { Binary, Key, ShieldCheck } from "lucide-react";
|
||||
import { Binary, Key } from "lucide-react";
|
||||
import SetResourcePasswordForm from "./SetResourcePasswordForm";
|
||||
import SetResourcePincodeForm from "./SetResourcePincodeForm";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
@@ -44,11 +38,12 @@ import {
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const UsersRolesFormSchema = z.object({
|
||||
roles: z.array(
|
||||
@@ -75,7 +70,6 @@ const whitelistSchema = z.object({
|
||||
});
|
||||
|
||||
export default function ResourceAuthenticationPage() {
|
||||
const { toast } = useToast();
|
||||
const { org } = useOrgContext();
|
||||
const { resource, updateResource, authInfo, updateAuthInfo } =
|
||||
useResourceContext();
|
||||
@@ -83,6 +77,7 @@ export default function ResourceAuthenticationPage() {
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
|
||||
@@ -237,6 +232,7 @@ export default function ResourceAuthenticationPage() {
|
||||
title: "Saved successfully",
|
||||
description: "Whitelist settings have been saved"
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast({
|
||||
@@ -284,6 +280,7 @@ export default function ResourceAuthenticationPage() {
|
||||
title: "Saved successfully",
|
||||
description: "Authentication settings have been saved"
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast({
|
||||
@@ -315,6 +312,7 @@ export default function ResourceAuthenticationPage() {
|
||||
updateAuthInfo({
|
||||
password: false
|
||||
});
|
||||
router.refresh();
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
@@ -345,6 +343,7 @@ export default function ResourceAuthenticationPage() {
|
||||
updateAuthInfo({
|
||||
pincode: false
|
||||
});
|
||||
router.refresh();
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
@@ -430,7 +429,6 @@ export default function ResourceAuthenticationPage() {
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>Roles</FormLabel>
|
||||
<FormControl>
|
||||
{/* @ts-ignore */}
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
@@ -439,7 +437,7 @@ export default function ResourceAuthenticationPage() {
|
||||
setActiveTagIndex={
|
||||
setActiveRolesTagIndex
|
||||
}
|
||||
placeholder="Enter a role"
|
||||
placeholder="Select a role"
|
||||
tags={
|
||||
usersRolesForm.getValues()
|
||||
.roles
|
||||
@@ -478,13 +476,11 @@ export default function ResourceAuthenticationPage() {
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
These roles will be able
|
||||
to access this resource.
|
||||
Admins can always access
|
||||
this resource.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -495,7 +491,6 @@ export default function ResourceAuthenticationPage() {
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>Users</FormLabel>
|
||||
<FormControl>
|
||||
{/* @ts-ignore */}
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
@@ -504,7 +499,7 @@ export default function ResourceAuthenticationPage() {
|
||||
setActiveTagIndex={
|
||||
setActiveUsersTagIndex
|
||||
}
|
||||
placeholder="Enter a user"
|
||||
placeholder="Select a user"
|
||||
tags={
|
||||
usersRolesForm.getValues()
|
||||
.users
|
||||
@@ -543,15 +538,6 @@ export default function ResourceAuthenticationPage() {
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Users added here will be
|
||||
able to access this
|
||||
resource. A user will
|
||||
always have access to a
|
||||
resource if they have a
|
||||
role that has access to
|
||||
it.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -732,6 +718,11 @@ export default function ResourceAuthenticationPage() {
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Press enter to add an
|
||||
email after typing it in
|
||||
the input field.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@app/components/ui/table";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { ArrayElement } from "@server/types/ArrayElement";
|
||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||
@@ -62,39 +62,11 @@ import {
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { useSiteContext } from "@app/hooks/useSiteContext";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
|
||||
// Regular expressions for validation
|
||||
const DOMAIN_REGEX =
|
||||
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||
const IPV4_REGEX =
|
||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
|
||||
|
||||
// Schema for domain names and IP addresses
|
||||
const domainSchema = z
|
||||
.string()
|
||||
.min(1, "Domain cannot be empty")
|
||||
.max(255, "Domain name too long")
|
||||
.refine(
|
||||
(value) => {
|
||||
// Check if it's a valid IP address (v4 or v6)
|
||||
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it's a valid domain name
|
||||
return DOMAIN_REGEX.test(value);
|
||||
},
|
||||
{
|
||||
message: "Invalid domain name or IP address format",
|
||||
path: ["domain"]
|
||||
}
|
||||
);
|
||||
import { useRouter } from "next/navigation";
|
||||
import { isTargetValid } from "@server/lib/validators";
|
||||
|
||||
const addTargetSchema = z.object({
|
||||
ip: domainSchema,
|
||||
ip: z.string().refine(isTargetValid),
|
||||
method: z.string().nullable(),
|
||||
port: z.coerce.number().int().positive()
|
||||
// protocol: z.string(),
|
||||
@@ -113,7 +85,6 @@ export default function ReverseProxyTargets(props: {
|
||||
}) {
|
||||
const params = use(props.params);
|
||||
|
||||
const { toast } = useToast();
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
@@ -126,15 +97,15 @@ export default function ReverseProxyTargets(props: {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
const addTargetForm = useForm({
|
||||
resolver: zodResolver(addTargetSchema),
|
||||
defaultValues: {
|
||||
ip: "",
|
||||
method: resource.http ? "http" : null,
|
||||
port: resource.http ? 80 : resource.proxyPort || 1234
|
||||
method: resource.http ? "http" : null
|
||||
// protocol: "TCP",
|
||||
}
|
||||
} as z.infer<typeof addTargetSchema>
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -270,7 +241,7 @@ export default function ReverseProxyTargets(props: {
|
||||
>(`/resource/${params.resourceId}/target`, data);
|
||||
target.targetId = res.data.data.targetId;
|
||||
} else if (target.updated) {
|
||||
const res = await api.post(
|
||||
await api.post(
|
||||
`/target/${target.targetId}`,
|
||||
data
|
||||
);
|
||||
@@ -291,7 +262,7 @@ export default function ReverseProxyTargets(props: {
|
||||
for (const targetId of targetsToRemove) {
|
||||
await api.delete(`/target/${targetId}`);
|
||||
setTargets(
|
||||
targets.filter((target) => target.targetId !== targetId)
|
||||
targets.filter((t) => t.targetId !== targetId)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -301,6 +272,7 @@ export default function ReverseProxyTargets(props: {
|
||||
});
|
||||
|
||||
setTargetsToRemove([]);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
@@ -317,17 +289,32 @@ export default function ReverseProxyTargets(props: {
|
||||
}
|
||||
|
||||
async function saveSsl(val: boolean) {
|
||||
const res = await api.post(`/resource/${params.resourceId}`, {
|
||||
ssl: val
|
||||
});
|
||||
const res = await api
|
||||
.post(`/resource/${params.resourceId}`, {
|
||||
ssl: val
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to update SSL configuration",
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred while updating the SSL configuration"
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
setSslEnabled(val);
|
||||
updateResource({ ssl: val });
|
||||
if (res && res.status === 200) {
|
||||
setSslEnabled(val);
|
||||
updateResource({ ssl: val });
|
||||
|
||||
toast({
|
||||
title: "SSL Configuration",
|
||||
description: "SSL configuration updated successfully"
|
||||
});
|
||||
toast({
|
||||
title: "SSL Configuration",
|
||||
description: "SSL configuration updated successfully"
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnDef<LocalTarget>[] = [
|
||||
@@ -434,6 +421,7 @@ export default function ReverseProxyTargets(props: {
|
||||
<SelectContent>
|
||||
<SelectItem value="http">http</SelectItem>
|
||||
<SelectItem value="https">https</SelectItem>
|
||||
<SelectItem value="h2c">h2c</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
@@ -449,7 +437,13 @@ export default function ReverseProxyTargets(props: {
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel()
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
state: {
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
pageSize: 1000
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (pageLoading) {
|
||||
@@ -465,8 +459,7 @@ export default function ReverseProxyTargets(props: {
|
||||
SSL Configuration
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Setup SSL to secure your connections with
|
||||
LetsEncrypt certificates
|
||||
Setup SSL to secure your connections with Let's Encrypt certificates
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -530,6 +523,9 @@ export default function ReverseProxyTargets(props: {
|
||||
<SelectItem value="https">
|
||||
https
|
||||
</SelectItem>
|
||||
<SelectItem value="h2c">
|
||||
h2c
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
@@ -653,7 +649,8 @@ export default function ReverseProxyTargets(props: {
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Adding more than one target above will enable load balancing.
|
||||
Adding more than one target above will enable load
|
||||
balancing.
|
||||
</p>
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
|
||||
@@ -14,14 +14,26 @@ import {
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem
|
||||
} from "@/components/ui/command";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@/components/ui/popover";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { GetResourceAuthInfoResponse } from "@server/routers/resource";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
@@ -36,14 +48,28 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import CustomDomainInput from "../CustomDomainInput";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
||||
import { subdomainSchema } from "@server/lib/schemas";
|
||||
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
||||
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import { ListDomainsResponse } from "@server/routers/domain";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||
|
||||
const GeneralFormSchema = z
|
||||
.object({
|
||||
subdomain: z.string().optional(),
|
||||
name: z.string().min(1).max(255),
|
||||
proxyPort: z.number().optional(),
|
||||
http: z.boolean()
|
||||
http: z.boolean(),
|
||||
isBaseDomain: z.boolean().optional(),
|
||||
domainId: z.string().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -64,7 +90,7 @@ const GeneralFormSchema = z
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.http) {
|
||||
if (data.http && !data.isBaseDomain) {
|
||||
return subdomainSchema.safeParse(data.subdomain).success;
|
||||
}
|
||||
return true;
|
||||
@@ -75,22 +101,37 @@ const GeneralFormSchema = z
|
||||
}
|
||||
);
|
||||
|
||||
const TransferFormSchema = z.object({
|
||||
siteId: z.number()
|
||||
});
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
type TransferFormValues = z.infer<typeof TransferFormSchema>;
|
||||
|
||||
export default function GeneralForm() {
|
||||
const [formKey, setFormKey] = useState(0);
|
||||
const params = useParams();
|
||||
const { toast } = useToast();
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
const { org } = useOrgContext();
|
||||
const router = useRouter();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const orgId = params.orgId;
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
const [domainSuffix, setDomainSuffix] = useState(org.org.domain);
|
||||
const [transferLoading, setTransferLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [baseDomains, setBaseDomains] = useState<
|
||||
ListDomainsResponse["domains"]
|
||||
>([]);
|
||||
|
||||
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
|
||||
resource.isBaseDomain ? "basedomain" : "subdomain"
|
||||
);
|
||||
|
||||
const form = useForm<GeneralFormValues>({
|
||||
resolver: zodResolver(GeneralFormSchema),
|
||||
@@ -98,11 +139,20 @@ export default function GeneralForm() {
|
||||
name: resource.name,
|
||||
subdomain: resource.subdomain ? resource.subdomain : undefined,
|
||||
proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
|
||||
http: resource.http
|
||||
http: resource.http,
|
||||
isBaseDomain: resource.isBaseDomain ? true : false,
|
||||
domainId: resource.domainId || undefined
|
||||
},
|
||||
mode: "onChange"
|
||||
});
|
||||
|
||||
const transferForm = useForm<TransferFormValues>({
|
||||
resolver: zodResolver(TransferFormSchema),
|
||||
defaultValues: {
|
||||
siteId: resource.siteId ? Number(resource.siteId) : undefined
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSites = async () => {
|
||||
const res = await api.get<AxiosResponse<ListSitesResponse>>(
|
||||
@@ -110,20 +160,48 @@ export default function GeneralForm() {
|
||||
);
|
||||
setSites(res.data.data.sites);
|
||||
};
|
||||
|
||||
const fetchDomains = async () => {
|
||||
const res = await api
|
||||
.get<
|
||||
AxiosResponse<ListDomainsResponse>
|
||||
>(`/org/${orgId}/domains/`)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error fetching domains",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred when fetching the domains"
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res?.status === 200) {
|
||||
const domains = res.data.data.domains;
|
||||
setBaseDomains(domains);
|
||||
setFormKey((key) => key + 1);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDomains();
|
||||
fetchSites();
|
||||
}, []);
|
||||
|
||||
async function onSubmit(data: GeneralFormValues) {
|
||||
setSaveLoading(true);
|
||||
|
||||
api.post<AxiosResponse<GetResourceAuthInfoResponse>>(
|
||||
`resource/${resource?.resourceId}`,
|
||||
{
|
||||
name: data.name,
|
||||
subdomain: data.subdomain
|
||||
// siteId: data.siteId,
|
||||
}
|
||||
)
|
||||
const res = await api
|
||||
.post<AxiosResponse<UpdateResourceResponse>>(
|
||||
`resource/${resource?.resourceId}`,
|
||||
{
|
||||
name: data.name,
|
||||
subdomain: data.http ? data.subdomain : undefined,
|
||||
proxyPort: data.proxyPort,
|
||||
isBaseDomain: data.http ? data.isBaseDomain : undefined,
|
||||
domainId: data.http ? data.domainId : undefined
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
@@ -133,18 +211,55 @@ export default function GeneralForm() {
|
||||
"An error occurred while updating the resource"
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
title: "Resource updated",
|
||||
description: "The resource has been updated successfully"
|
||||
});
|
||||
|
||||
const resource = res.data.data;
|
||||
|
||||
updateResource({
|
||||
name: data.name,
|
||||
subdomain: data.subdomain,
|
||||
proxyPort: data.proxyPort,
|
||||
isBaseDomain: data.isBaseDomain,
|
||||
fullDomain: resource.fullDomain
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
}
|
||||
setSaveLoading(false);
|
||||
}
|
||||
|
||||
async function onTransfer(data: TransferFormValues) {
|
||||
setTransferLoading(true);
|
||||
|
||||
const res = await api
|
||||
.post(`resource/${resource?.resourceId}/transfer`, {
|
||||
siteId: data.siteId
|
||||
})
|
||||
.then(() => {
|
||||
.catch((e) => {
|
||||
toast({
|
||||
title: "Resource updated",
|
||||
description: "The resource has been updated successfully"
|
||||
variant: "destructive",
|
||||
title: "Failed to transfer resource",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while transferring the resource"
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
updateResource({ name: data.name, subdomain: data.subdomain });
|
||||
|
||||
router.refresh();
|
||||
})
|
||||
.finally(() => setSaveLoading(false));
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
title: "Resource transferred",
|
||||
description: "The resource has been transferred successfully"
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
setTransferLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -161,7 +276,7 @@ export default function GeneralForm() {
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<Form {...form} key={formKey}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
@@ -176,49 +291,191 @@ export default function GeneralForm() {
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is the display name of the
|
||||
resource.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{resource.http ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subdomain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Subdomain</FormLabel>
|
||||
<FormControl>
|
||||
<CustomDomainInput
|
||||
value={
|
||||
field.value || ""
|
||||
}
|
||||
domainSuffix={
|
||||
domainSuffix
|
||||
}
|
||||
placeholder="Enter subdomain"
|
||||
onChange={(value) =>
|
||||
form.setValue(
|
||||
"subdomain",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is the subdomain that
|
||||
will be used to access the
|
||||
resource.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
{resource.http && (
|
||||
<>
|
||||
{env.flags.allowBaseDomainResources && (
|
||||
<div>
|
||||
<RadioGroup
|
||||
className="flex space-x-4"
|
||||
defaultValue={domainType}
|
||||
onValueChange={(val) => {
|
||||
setDomainType(
|
||||
val as any
|
||||
);
|
||||
form.setValue(
|
||||
"isBaseDomain",
|
||||
val === "basedomain"
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="subdomain"
|
||||
id="r1"
|
||||
/>
|
||||
<Label htmlFor="r1">
|
||||
Subdomain
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="basedomain"
|
||||
id="r2"
|
||||
/>
|
||||
<Label htmlFor="r2">
|
||||
Base Domain
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
|
||||
{domainType === "subdomain" ? (
|
||||
<div className="w-fill space-y-2">
|
||||
{!env.flags
|
||||
.allowBaseDomainResources && (
|
||||
<FormLabel>
|
||||
Subdomain
|
||||
</FormLabel>
|
||||
)}
|
||||
<div className="flex">
|
||||
<div className="w-full mr-1">
|
||||
<FormField
|
||||
control={
|
||||
form.control
|
||||
}
|
||||
name="subdomain"
|
||||
render={({
|
||||
field
|
||||
}) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="text-right"
|
||||
placeholder="Enter subdomain"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-1/2">
|
||||
<FormField
|
||||
control={
|
||||
form.control
|
||||
}
|
||||
name="domainId"
|
||||
render={({
|
||||
field
|
||||
}) => (
|
||||
<FormItem>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
defaultValue={
|
||||
field.value
|
||||
}
|
||||
value={
|
||||
field.value
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{baseDomains.map(
|
||||
(
|
||||
option
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
option.domainId
|
||||
}
|
||||
value={
|
||||
option.domainId
|
||||
}
|
||||
>
|
||||
.
|
||||
{
|
||||
option.baseDomain
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domainId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
defaultValue={
|
||||
field.value ||
|
||||
baseDomains[0]
|
||||
?.domainId
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{baseDomains.map(
|
||||
(
|
||||
option
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
option.domainId
|
||||
}
|
||||
value={
|
||||
option.domainId
|
||||
}
|
||||
>
|
||||
{
|
||||
option.baseDomain
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!resource.http && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="proxyPort"
|
||||
@@ -230,7 +487,6 @@ export default function GeneralForm() {
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Enter port number"
|
||||
value={
|
||||
field.value ?? ""
|
||||
}
|
||||
@@ -247,12 +503,12 @@ export default function GeneralForm() {
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is the port that will
|
||||
be used to access the
|
||||
resource.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -273,6 +529,125 @@ export default function GeneralForm() {
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Transfer Resource
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Transfer this resource to a different site
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...transferForm}>
|
||||
<form
|
||||
onSubmit={transferForm.handleSubmit(onTransfer)}
|
||||
className="space-y-4"
|
||||
id="transfer-form"
|
||||
>
|
||||
<FormField
|
||||
control={transferForm.control}
|
||||
name="siteId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Destination Site
|
||||
</FormLabel>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
!field.value &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? sites.find(
|
||||
(site) =>
|
||||
site.siteId ===
|
||||
field.value
|
||||
)?.name
|
||||
: "Select site"}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search sites"
|
||||
className="h-9"
|
||||
/>
|
||||
<CommandEmpty>
|
||||
No sites found.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sites.map(
|
||||
(site) => (
|
||||
<CommandItem
|
||||
value={`${site.name}:${site.siteId}`}
|
||||
key={
|
||||
site.siteId
|
||||
}
|
||||
onSelect={() => {
|
||||
transferForm.setValue(
|
||||
"siteId",
|
||||
site.siteId
|
||||
);
|
||||
setOpen(
|
||||
false
|
||||
);
|
||||
}}
|
||||
>
|
||||
{
|
||||
site.name
|
||||
}
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
site.siteId ===
|
||||
field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={transferLoading}
|
||||
disabled={transferLoading}
|
||||
form="transfer-form"
|
||||
>
|
||||
Transfer Resource
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,6 +99,11 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||
href: `/{orgId}/settings/resources/{resourceId}/authentication`
|
||||
// icon: <Shield className="w-4 h-4" />,
|
||||
});
|
||||
sidebarNavItems.push({
|
||||
title: "Rules",
|
||||
href: `/{orgId}/settings/resources/{resourceId}/rules`
|
||||
// icon: <Shield className="w-4 h-4" />,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
789
src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx
Normal file
789
src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx
Normal file
@@ -0,0 +1,789 @@
|
||||
"use client";
|
||||
import { useEffect, useState, use } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import {
|
||||
ColumnDef,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
flexRender
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@app/components/ui/table";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { ArrayElement } from "@server/types/ArrayElement";
|
||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { ArrowUpDown, Check, InfoIcon, X } from "lucide-react";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import {
|
||||
isValidCIDR,
|
||||
isValidIP,
|
||||
isValidUrlGlobPattern
|
||||
} from "@server/lib/validators";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
// Schema for rule validation
|
||||
const addRuleSchema = z.object({
|
||||
action: z.string(),
|
||||
match: z.string(),
|
||||
value: z.string(),
|
||||
priority: z.coerce.number().int().optional()
|
||||
});
|
||||
|
||||
type LocalRule = ArrayElement<ListResourceRulesResponse["rules"]> & {
|
||||
new?: boolean;
|
||||
updated?: boolean;
|
||||
};
|
||||
|
||||
enum RuleAction {
|
||||
ACCEPT = "Always Allow",
|
||||
DROP = "Always Deny"
|
||||
}
|
||||
|
||||
enum RuleMatch {
|
||||
PATH = "Path",
|
||||
IP = "IP",
|
||||
CIDR = "IP Range",
|
||||
}
|
||||
|
||||
export default function ResourceRules(props: {
|
||||
params: Promise<{ resourceId: number }>;
|
||||
}) {
|
||||
const params = use(props.params);
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [rules, setRules] = useState<LocalRule[]>([]);
|
||||
const [rulesToRemove, setRulesToRemove] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules);
|
||||
const router = useRouter();
|
||||
|
||||
const addRuleForm = useForm({
|
||||
resolver: zodResolver(addRuleSchema),
|
||||
defaultValues: {
|
||||
action: "ACCEPT",
|
||||
match: "IP",
|
||||
value: ""
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRules = async () => {
|
||||
try {
|
||||
const res = await api.get<
|
||||
AxiosResponse<ListResourceRulesResponse>
|
||||
>(`/resource/${params.resourceId}/rules`);
|
||||
if (res.status === 200) {
|
||||
setRules(res.data.data.rules);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch rules",
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred while fetching rules"
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
setPageLoading(false);
|
||||
}
|
||||
};
|
||||
fetchRules();
|
||||
}, []);
|
||||
|
||||
async function addRule(data: z.infer<typeof addRuleSchema>) {
|
||||
const isDuplicate = rules.some(
|
||||
(rule) =>
|
||||
rule.action === data.action &&
|
||||
rule.match === data.match &&
|
||||
rule.value === data.value
|
||||
);
|
||||
|
||||
if (isDuplicate) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Duplicate rule",
|
||||
description: "A rule with these settings already exists"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.match === "CIDR" && !isValidCIDR(data.value)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid CIDR",
|
||||
description: "Please enter a valid CIDR value"
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid URL path",
|
||||
description: "Please enter a valid URL path value"
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (data.match === "IP" && !isValidIP(data.value)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid IP",
|
||||
description: "Please enter a valid IP address"
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// find the highest priority and add one
|
||||
let priority = data.priority;
|
||||
if (priority === undefined) {
|
||||
priority = rules.reduce(
|
||||
(acc, rule) => (rule.priority > acc ? rule.priority : acc),
|
||||
0
|
||||
);
|
||||
priority++;
|
||||
}
|
||||
|
||||
const newRule: LocalRule = {
|
||||
...data,
|
||||
ruleId: new Date().getTime(),
|
||||
new: true,
|
||||
resourceId: resource.resourceId,
|
||||
priority,
|
||||
enabled: true
|
||||
};
|
||||
|
||||
setRules([...rules, newRule]);
|
||||
addRuleForm.reset();
|
||||
}
|
||||
|
||||
const removeRule = (ruleId: number) => {
|
||||
setRules([...rules.filter((rule) => rule.ruleId !== ruleId)]);
|
||||
if (!rules.find((rule) => rule.ruleId === ruleId)?.new) {
|
||||
setRulesToRemove([...rulesToRemove, ruleId]);
|
||||
}
|
||||
};
|
||||
|
||||
async function updateRule(ruleId: number, data: Partial<LocalRule>) {
|
||||
setRules(
|
||||
rules.map((rule) =>
|
||||
rule.ruleId === ruleId
|
||||
? { ...rule, ...data, updated: true }
|
||||
: rule
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function saveApplyRules(val: boolean) {
|
||||
const res = await api
|
||||
.post(`/resource/${params.resourceId}`, {
|
||||
applyRules: val
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to update rules",
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred while updating rules"
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
setRulesEnabled(val);
|
||||
updateResource({ applyRules: val });
|
||||
|
||||
toast({
|
||||
title: "Enable Rules",
|
||||
description: "Rule evaluation has been updated"
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function getValueHelpText(type: string) {
|
||||
switch (type) {
|
||||
case "CIDR":
|
||||
return "Enter an address in CIDR format (e.g., 103.21.244.0/22)";
|
||||
case "IP":
|
||||
return "Enter an IP address (e.g., 103.21.244.12)";
|
||||
case "PATH":
|
||||
return "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)";
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRules() {
|
||||
try {
|
||||
setLoading(true);
|
||||
for (let rule of rules) {
|
||||
const data = {
|
||||
action: rule.action,
|
||||
match: rule.match,
|
||||
value: rule.value,
|
||||
priority: rule.priority,
|
||||
enabled: rule.enabled
|
||||
};
|
||||
|
||||
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid CIDR",
|
||||
description: "Please enter a valid CIDR value"
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
rule.match === "PATH" &&
|
||||
!isValidUrlGlobPattern(rule.value)
|
||||
) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid URL path",
|
||||
description: "Please enter a valid URL path value"
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (rule.match === "IP" && !isValidIP(rule.value)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid IP",
|
||||
description: "Please enter a valid IP address"
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rule.priority === undefined) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid Priority",
|
||||
description: "Please enter a valid priority"
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// make sure no duplicate priorities
|
||||
const priorities = rules.map((r) => r.priority);
|
||||
if (priorities.length !== new Set(priorities).size) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Duplicate Priorities",
|
||||
description: "Please enter unique priorities"
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rule.new) {
|
||||
const res = await api.put(
|
||||
`/resource/${params.resourceId}/rule`,
|
||||
data
|
||||
);
|
||||
rule.ruleId = res.data.data.ruleId;
|
||||
} else if (rule.updated) {
|
||||
await api.post(
|
||||
`/resource/${params.resourceId}/rule/${rule.ruleId}`,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
setRules([
|
||||
...rules.map((r) => {
|
||||
let res = {
|
||||
...r,
|
||||
new: false,
|
||||
updated: false
|
||||
};
|
||||
return res;
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
for (const ruleId of rulesToRemove) {
|
||||
await api.delete(
|
||||
`/resource/${params.resourceId}/rule/${ruleId}`
|
||||
);
|
||||
setRules(rules.filter((r) => r.ruleId !== ruleId));
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Rules updated",
|
||||
description: "Rules updated successfully"
|
||||
});
|
||||
|
||||
setRulesToRemove([]);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Operation failed",
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred during the save operation"
|
||||
)
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const columns: ColumnDef<LocalRule>[] = [
|
||||
{
|
||||
accessorKey: "priority",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Priority
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<Input
|
||||
defaultValue={row.original.priority}
|
||||
className="w-[75px]"
|
||||
type="number"
|
||||
onBlur={(e) => {
|
||||
const parsed = z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.safeParse(e.target.value);
|
||||
|
||||
if (!parsed.data) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid IP",
|
||||
description: "Please enter a valid priority"
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
updateRule(row.original.ruleId, {
|
||||
priority: parsed.data
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "action",
|
||||
header: "Action",
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.action}
|
||||
onValueChange={(value: "ACCEPT" | "DROP") =>
|
||||
updateRule(row.original.ruleId, { action: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="min-w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ACCEPT">
|
||||
{RuleAction.ACCEPT}
|
||||
</SelectItem>
|
||||
<SelectItem value="DROP">{RuleAction.DROP}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "match",
|
||||
header: "Match Type",
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.match}
|
||||
onValueChange={(value: "CIDR" | "IP" | "PATH") =>
|
||||
updateRule(row.original.ruleId, { match: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="min-w-[125px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
|
||||
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
|
||||
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "value",
|
||||
header: "Value",
|
||||
cell: ({ row }) => (
|
||||
<Input
|
||||
defaultValue={row.original.value}
|
||||
className="min-w-[200px]"
|
||||
onBlur={(e) =>
|
||||
updateRule(row.original.ruleId, {
|
||||
value: e.target.value
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
header: "Enabled",
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.original.enabled}
|
||||
onCheckedChange={(val) =>
|
||||
updateRule(row.original.ruleId, { enabled: val })
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => removeRule(row.original.ruleId)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: rules,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
state: {
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
pageSize: 1000
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (pageLoading) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<Alert className="hidden md:block">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">About Rules</AlertTitle>
|
||||
<AlertDescription className="mt-4">
|
||||
<div className="space-y-1 mb-4">
|
||||
<p>
|
||||
Rules allow you to control access to your resource
|
||||
based on a set of criteria. You can create rules to
|
||||
allow or deny access based on IP address or URL
|
||||
path.
|
||||
</p>
|
||||
</div>
|
||||
<InfoSections>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Actions</InfoSectionTitle>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="text-green-500 w-4 h-4" />
|
||||
Always Allow: Bypass all authentication
|
||||
methods
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<X className="text-red-500 w-4 h-4" />
|
||||
Always Deny: Block all requests; no
|
||||
authentication can be attempted
|
||||
</li>
|
||||
</ul>
|
||||
</InfoSection>
|
||||
<Separator orientation="vertical" />
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Matching Criteria
|
||||
</InfoSectionTitle>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li className="flex items-center gap-2">
|
||||
Match a specific IP address
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
Match a range of IP addresses in CIDR
|
||||
notation
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
Match a URL path or pattern
|
||||
</li>
|
||||
</ul>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>Enable Rules</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Enable or disable rule evaluation for this resource
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SwitchInput
|
||||
id="rules-toggle"
|
||||
label="Enable Rules"
|
||||
defaultChecked={rulesEnabled}
|
||||
onCheckedChange={async (val) => {
|
||||
await saveApplyRules(val);
|
||||
}}
|
||||
/>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Resource Rules Configuration
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure rules to control access to your resource
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<Form {...addRuleForm}>
|
||||
<form
|
||||
onSubmit={addRuleForm.handleSubmit(addRule)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField
|
||||
control={addRuleForm.control}
|
||||
name="action"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Action</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ACCEPT">
|
||||
{RuleAction.ACCEPT}
|
||||
</SelectItem>
|
||||
<SelectItem value="DROP">
|
||||
{RuleAction.DROP}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={addRuleForm.control}
|
||||
name="match"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Match Type</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{resource.http && (
|
||||
<SelectItem value="PATH">
|
||||
{RuleMatch.PATH}
|
||||
</SelectItem>
|
||||
)}
|
||||
<SelectItem value="IP">
|
||||
{RuleMatch.IP}
|
||||
</SelectItem>
|
||||
<SelectItem value="CIDR">
|
||||
{RuleMatch.CIDR}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={addRuleForm.control}
|
||||
name="value"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<InfoPopup
|
||||
text="Value"
|
||||
info={
|
||||
getValueHelpText(
|
||||
addRuleForm.watch(
|
||||
"match"
|
||||
)
|
||||
) || ""
|
||||
}
|
||||
/>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={!rulesEnabled}
|
||||
>
|
||||
Add Rule
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column
|
||||
.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column
|
||||
.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No rules. Add a rule using the form.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Rules are evaluated by priority in ascending order.
|
||||
</p>
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
onClick={saveRules}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Save Rules
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -56,11 +56,14 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||
protocol: resource.protocol,
|
||||
proxyPort: resource.proxyPort,
|
||||
http: resource.http,
|
||||
hasAuth:
|
||||
resource.sso ||
|
||||
resource.pincodeId !== null ||
|
||||
resource.pincodeId !== null ||
|
||||
resource.whitelist
|
||||
authState: !resource.http
|
||||
? "none"
|
||||
: resource.sso ||
|
||||
resource.pincodeId !== null ||
|
||||
resource.pincodeId !== null ||
|
||||
resource.whitelist
|
||||
? "protected"
|
||||
: "not_protected"
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { InviteUserBody, InviteUserResponse } from "@server/routers/user";
|
||||
import { AxiosResponse } from "axios";
|
||||
@@ -94,7 +94,6 @@ export default function CreateShareLinkForm({
|
||||
setOpen,
|
||||
onCreated
|
||||
}: FormProps) {
|
||||
const { toast } = useToast();
|
||||
const { org } = useOrgContext();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
@@ -306,7 +305,7 @@ export default function CreateShareLinkForm({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search resources..." />
|
||||
<CommandInput placeholder="Search resources" />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No
|
||||
@@ -320,7 +319,7 @@ export default function CreateShareLinkForm({
|
||||
) => (
|
||||
<CommandItem
|
||||
value={
|
||||
r.resourceId.toString()
|
||||
`${r.name}:${r.resourceId}`
|
||||
}
|
||||
key={
|
||||
r.resourceId
|
||||
@@ -375,7 +374,6 @@ export default function CreateShareLinkForm({
|
||||
</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter title"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -438,7 +436,6 @@ export default function CreateShareLinkForm({
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="Enter duration"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -50,13 +50,15 @@ export function ShareLinksDataTable<TData, TValue>({
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 100,
|
||||
pageSize: 20,
|
||||
pageIndex: 0
|
||||
}
|
||||
},
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { ArrayElement } from "@server/types/ArrayElement";
|
||||
@@ -54,8 +54,6 @@ export default function ShareLinksTable({
|
||||
}: ShareLinksTableProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -38,7 +38,16 @@ import { SiteRow } from "./SitesTable";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { ArrowUpRight, SquareArrowOutUpRight } from "lucide-react";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ChevronsUpDown,
|
||||
SquareArrowOutUpRight
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger
|
||||
} from "@app/components/ui/collapsible";
|
||||
|
||||
const createSiteFormSchema = z.object({
|
||||
name: z
|
||||
@@ -72,13 +81,14 @@ export default function CreateSiteForm({
|
||||
setChecked,
|
||||
orgId
|
||||
}: CreateSiteFormProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isChecked, setIsChecked] = useState(false);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const [keypair, setKeypair] = useState<{
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
@@ -183,10 +193,9 @@ export default function CreateSiteForm({
|
||||
}
|
||||
|
||||
const res = await api
|
||||
.put<AxiosResponse<CreateSiteResponse>>(
|
||||
`/org/${orgId}/site/`,
|
||||
payload
|
||||
)
|
||||
.put<
|
||||
AxiosResponse<CreateSiteResponse>
|
||||
>(`/org/${orgId}/site/`, payload)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
@@ -234,13 +243,19 @@ Endpoint = ${siteDefaults.endpoint}:${siteDefaults.listenPort}
|
||||
PersistentKeepalive = 5`
|
||||
: "";
|
||||
|
||||
// am I at http or https?
|
||||
let proto = "https:";
|
||||
// if (typeof window !== "undefined") {
|
||||
// proto = window.location.protocol;
|
||||
// }
|
||||
const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`;
|
||||
|
||||
const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${proto}//${siteDefaults?.endpoint}`;
|
||||
const newtConfigDockerCompose = `services:
|
||||
newt:
|
||||
image: fosrl/newt
|
||||
container_name: newt
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PANGOLIN_ENDPOINT=${env.app.dashboardUrl}
|
||||
- NEWT_ID=${siteDefaults?.newtId}
|
||||
- NEWT_SECRET=${siteDefaults?.newtSecret}`;
|
||||
|
||||
const newtConfigDockerRun = `docker run -it fosrl/newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -257,17 +272,13 @@ PersistentKeepalive = 5`
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
placeholder="Site name"
|
||||
{...field}
|
||||
/>
|
||||
<Input autoComplete="off" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is the name that will be displayed for
|
||||
this site.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is the the display name for the
|
||||
site.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -304,40 +315,14 @@ PersistentKeepalive = 5`
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is how you will expose connections.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="w-full">
|
||||
{form.watch("method") === "wireguard" && !isLoading ? (
|
||||
<>
|
||||
<CopyTextBox text={wgConfig} />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
You will only be able to see the
|
||||
configuration once.
|
||||
</span>
|
||||
</>
|
||||
) : form.watch("method") === "wireguard" &&
|
||||
isLoading ? (
|
||||
<p>Loading WireGuard configuration...</p>
|
||||
) : form.watch("method") === "newt" ? (
|
||||
<>
|
||||
<CopyTextBox
|
||||
text={newtConfig}
|
||||
wrapText={false}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
You will only be able to see the
|
||||
configuration once.
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{form.watch("method") === "newt" && (
|
||||
<Link
|
||||
className="text-sm text-primary flex items-center gap-1"
|
||||
@@ -353,6 +338,81 @@ PersistentKeepalive = 5`
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="w-full">
|
||||
{form.watch("method") === "wireguard" && !isLoading ? (
|
||||
<>
|
||||
<CopyTextBox text={wgConfig} />
|
||||
<span className="text-sm text-muted-foreground mt-2">
|
||||
You will only be able to see the
|
||||
configuration once.
|
||||
</span>
|
||||
</>
|
||||
) : form.watch("method") === "wireguard" &&
|
||||
isLoading ? (
|
||||
<p>Loading WireGuard configuration...</p>
|
||||
) : form.watch("method") === "newt" && siteDefaults ? (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="mx-auto">
|
||||
<CopyTextBox
|
||||
text={newtConfig}
|
||||
wrapText={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="p-0 flex items-center justify-between w-full"
|
||||
>
|
||||
<h4 className="text-sm font-semibold">
|
||||
Expand for Docker
|
||||
Deployment Details
|
||||
</h4>
|
||||
<div>
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
Toggle
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<CollapsibleContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<b>Docker Compose</b>
|
||||
<CopyTextBox
|
||||
text={
|
||||
newtConfigDockerCompose
|
||||
}
|
||||
wrapText={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<b>Docker Run</b>
|
||||
|
||||
<CopyTextBox
|
||||
text={newtConfigDockerRun}
|
||||
wrapText={false}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
You will only be able to see the
|
||||
configuration once.
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{form.watch("method") === "local" && (
|
||||
<Link
|
||||
className="text-sm text-primary flex items-center gap-1"
|
||||
@@ -360,10 +420,7 @@ PersistentKeepalive = 5`
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span>
|
||||
{" "}
|
||||
Local sites do not tunnel, learn more
|
||||
</span>
|
||||
<span> Local sites do not tunnel, learn more</span>
|
||||
<SquareArrowOutUpRight size={14} />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel,
|
||||
getFilteredRowModel
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableRow
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useState } from "react";
|
||||
@@ -36,7 +36,7 @@ interface DataTableProps<TData, TValue> {
|
||||
export function SitesDataTable<TData, TValue>({
|
||||
addSite,
|
||||
columns,
|
||||
data,
|
||||
data
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
@@ -50,14 +50,16 @@ export function SitesDataTable<TData, TValue>({
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 20,
|
||||
pageIndex: 0
|
||||
}
|
||||
},
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
pagination: {
|
||||
pageSize: 100,
|
||||
pageIndex: 0,
|
||||
},
|
||||
},
|
||||
columnFilters
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -103,7 +105,7 @@ export function SitesDataTable<TData, TValue>({
|
||||
: flexRender(
|
||||
header.column.columnDef
|
||||
.header,
|
||||
header.getContext(),
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
@@ -124,7 +126,7 @@ export function SitesDataTable<TData, TValue>({
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { AxiosResponse } from "axios";
|
||||
import { useState } from "react";
|
||||
import CreateSiteForm from "./CreateSiteForm";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
@@ -47,8 +47,6 @@ type SitesTableProps = {
|
||||
export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useSiteContext } from "@app/hooks/useSiteContext";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
SettingsContainer,
|
||||
@@ -33,14 +33,13 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useState } from "react";
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string()
|
||||
name: z.string().nonempty("Name is required")
|
||||
});
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
|
||||
export default function GeneralPage() {
|
||||
const { site, updateSite } = useSiteContext();
|
||||
const { toast } = useToast();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
@@ -115,11 +114,11 @@ export default function GeneralPage() {
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is the display name of the
|
||||
site
|
||||
site.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user