mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-18 10:56:38 +00:00
Compare commits
36 Commits
1.0.0-beta
...
1.0.0-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d27ecaae5e | ||
|
|
f0898613a2 | ||
|
|
40a2933e25 | ||
|
|
a208ab36b8 | ||
|
|
680c665242 | ||
|
|
6b141c3ea0 | ||
|
|
e4fe749251 | ||
|
|
ed5e6ec0f7 | ||
|
|
1aec431c36 | ||
|
|
cb87463a69 | ||
|
|
4b5c74e8d6 | ||
|
|
ab18e15a71 | ||
|
|
7ff5376d13 | ||
|
|
516c68224a | ||
|
|
7b93fbeba3 | ||
|
|
f958067139 | ||
|
|
4e606836a1 | ||
|
|
5da5ee3581 | ||
|
|
302ac2e644 | ||
|
|
baab56b6d8 | ||
|
|
79c4f13440 | ||
|
|
7b3db11b82 | ||
|
|
3ffca75915 | ||
|
|
f72dd3471e | ||
|
|
3f55103542 | ||
|
|
b39fe87eea | ||
|
|
bfc81e52b0 | ||
|
|
54f5d159a5 | ||
|
|
a2ed7c7117 | ||
|
|
161e87dbda | ||
|
|
4c7581df4f | ||
|
|
bfd1b21f9c | ||
|
|
84ee25e441 | ||
|
|
47683f2b8c | ||
|
|
81f1f48045 | ||
|
|
f5fda5d8ea |
@@ -27,6 +27,8 @@ COPY --from=builder /app/dist ./dist
|
|||||||
COPY --from=builder /app/init ./dist/init
|
COPY --from=builder /app/init ./dist/init
|
||||||
|
|
||||||
COPY config/config.example.yml ./dist/config.example.yml
|
COPY config/config.example.yml ./dist/config.example.yml
|
||||||
|
COPY config/traefik/traefik_config.example.yml ./dist/traefik_config.example.yml
|
||||||
|
COPY config/traefik/dynamic_config.example.yml ./dist/dynamic_config.example.yml
|
||||||
COPY server/db/names.json ./dist/names.json
|
COPY server/db/names.json ./dist/names.json
|
||||||
|
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ Pangolin is a self-hosted tunneled reverse proxy management server with identity
|
|||||||
- [Installation Instructions](https://docs.fossorial.io/Getting%20Started/quick-install)
|
- [Installation Instructions](https://docs.fossorial.io/Getting%20Started/quick-install)
|
||||||
- [Full Documentation](https://docs.fossorial.io)
|
- [Full Documentation](https://docs.fossorial.io)
|
||||||
|
|
||||||
|
### Authors and Maintainers
|
||||||
|
|
||||||
|
- [Milo Schwartz](https://github.com/miloschwartz)
|
||||||
|
- [Owen Schwartz](https://github.com/oschwartz10612)
|
||||||
|
|
||||||
## Preview
|
## Preview
|
||||||
|
|
||||||
<img src="public/screenshots/sites.png" alt="Preview"/>
|
<img src="public/screenshots/sites.png" alt="Preview"/>
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
app:
|
app:
|
||||||
dashboard_url: http://localhost
|
dashboard_url: http://localhost:3002
|
||||||
base_domain: localhost
|
base_domain: localhost
|
||||||
log_level: debug
|
log_level: info
|
||||||
save_logs: false
|
save_logs: false
|
||||||
|
|
||||||
server:
|
server:
|
||||||
external_port: 3000
|
external_port: 3000
|
||||||
internal_port: 3001
|
internal_port: 3001
|
||||||
next_port: 3002
|
next_port: 3002
|
||||||
internal_hostname: localhost
|
internal_hostname: pangolin
|
||||||
secure_cookies: false
|
secure_cookies: true
|
||||||
session_cookie_name: p_session
|
session_cookie_name: p_session
|
||||||
resource_session_cookie_name: p_resource_session
|
resource_session_cookie_name: p_resource_session
|
||||||
|
resource_access_token_param: p_token
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
cert_resolver: letsencrypt
|
cert_resolver: letsencrypt
|
||||||
@@ -38,3 +39,5 @@ users:
|
|||||||
|
|
||||||
flags:
|
flags:
|
||||||
require_email_verification: false
|
require_email_verification: false
|
||||||
|
disable_signup_without_invite: true
|
||||||
|
disable_user_create_org: true
|
||||||
|
|||||||
54
config/traefik/dynamic_config.example.yml
Normal file
54
config/traefik/dynamic_config.example.yml
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
http:
|
||||||
|
middlewares:
|
||||||
|
redirect-to-https:
|
||||||
|
redirectScheme:
|
||||||
|
scheme: https
|
||||||
|
permanent: true
|
||||||
|
|
||||||
|
routers:
|
||||||
|
# HTTP to HTTPS redirect router
|
||||||
|
main-app-router-redirect:
|
||||||
|
rule: "Host(`{{.DashboardDomain}}`)"
|
||||||
|
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`)"
|
||||||
|
service: next-service
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
|
|
||||||
|
# API router (handles /api/v1 paths)
|
||||||
|
api-router:
|
||||||
|
rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)"
|
||||||
|
service: api-service
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
|
|
||||||
|
# WebSocket router
|
||||||
|
ws-router:
|
||||||
|
rule: "Host(`{{.DashboardDomain}}`)"
|
||||||
|
service: api-service
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
|
|
||||||
|
services:
|
||||||
|
next-service:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://pangolin:{{.NEXT_PORT}}" # Next.js server
|
||||||
|
|
||||||
|
api-service:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://pangolin:{{.EXTERNAL_PORT}}" # API/WebSocket server
|
||||||
41
config/traefik/traefik_config.example.yml
Normal file
41
config/traefik/traefik_config.example.yml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
api:
|
||||||
|
insecure: true
|
||||||
|
dashboard: true
|
||||||
|
|
||||||
|
providers:
|
||||||
|
http:
|
||||||
|
endpoint: "http://pangolin:{{.INTERNAL_PORT}}/api/v1/traefik-config"
|
||||||
|
pollInterval: "5s"
|
||||||
|
file:
|
||||||
|
filename: "/etc/traefik/dynamic_config.yml"
|
||||||
|
|
||||||
|
experimental:
|
||||||
|
plugins:
|
||||||
|
badger:
|
||||||
|
moduleName: "github.com/fosrl/badger"
|
||||||
|
version: "v1.0.0-beta.2"
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: "INFO"
|
||||||
|
format: "common"
|
||||||
|
|
||||||
|
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"
|
||||||
|
http:
|
||||||
|
tls:
|
||||||
|
certResolver: "letsencrypt"
|
||||||
|
|
||||||
|
serversTransport:
|
||||||
|
insecureSkipVerify: true
|
||||||
@@ -9,9 +9,15 @@ server:
|
|||||||
internal_port: 3001
|
internal_port: 3001
|
||||||
next_port: 3002
|
next_port: 3002
|
||||||
internal_hostname: pangolin
|
internal_hostname: pangolin
|
||||||
secure_cookies: false
|
secure_cookies: true
|
||||||
session_cookie_name: p_session
|
session_cookie_name: p_session
|
||||||
resource_session_cookie_name: p_resource_session
|
resource_session_cookie_name: p_resource_session
|
||||||
|
resource_access_token_param: p_token
|
||||||
|
cors:
|
||||||
|
origins: ["https://{{.DashboardDomain}}"]
|
||||||
|
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
||||||
|
headers: ["X-CSRF-Token", "Content-Type"]
|
||||||
|
credentials: false
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
cert_resolver: letsencrypt
|
cert_resolver: letsencrypt
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
pangolin:
|
pangolin:
|
||||||
image: fosrl/pangolin:latest
|
image: fosrl/pangolin:{{.PangolinVersion}}
|
||||||
container_name: pangolin
|
container_name: pangolin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
@@ -11,8 +11,9 @@ services:
|
|||||||
timeout: "3s"
|
timeout: "3s"
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
{{if .InstallGerbil}}
|
||||||
gerbil:
|
gerbil:
|
||||||
image: fosrl/gerbil:latest
|
image: fosrl/gerbil:{{.GerbilVersion}}
|
||||||
container_name: gerbil
|
container_name: gerbil
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -32,12 +33,20 @@ services:
|
|||||||
- 51820:51820/udp
|
- 51820:51820/udp
|
||||||
- 443:443 # 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
|
- 80:80 # Port for traefik because of the network_mode
|
||||||
|
{{end}}
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:v3.1
|
image: traefik:v3.1
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
{{if .InstallGerbil}}
|
||||||
network_mode: service:gerbil # Ports appear on the gerbil service
|
network_mode: service:gerbil # Ports appear on the gerbil service
|
||||||
|
{{end}}
|
||||||
|
{{if not .InstallGerbil}}
|
||||||
|
ports:
|
||||||
|
- 443:443
|
||||||
|
- 80:80
|
||||||
|
{{end}}
|
||||||
depends_on:
|
depends_on:
|
||||||
pangolin:
|
pangolin:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ experimental:
|
|||||||
plugins:
|
plugins:
|
||||||
badger:
|
badger:
|
||||||
moduleName: "github.com/fosrl/badger"
|
moduleName: "github.com/fosrl/badger"
|
||||||
version: "v1.0.0-beta.1"
|
version: "v1.0.0-beta.2"
|
||||||
|
|
||||||
log:
|
log:
|
||||||
level: "INFO"
|
level: "INFO"
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
module installer
|
module installer
|
||||||
|
|
||||||
go 1.23.0
|
go 1.23.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
golang.org/x/sys v0.29.0 // indirect
|
||||||
|
golang.org/x/term v0.28.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
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=
|
||||||
|
|||||||
@@ -10,27 +10,38 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"text/template"
|
"text/template"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func loadVersions(config *Config) {
|
||||||
|
config.PangolinVersion = "1.0.0-beta.7"
|
||||||
|
config.GerbilVersion = "1.0.0-beta.2"
|
||||||
|
}
|
||||||
|
|
||||||
//go:embed fs/*
|
//go:embed fs/*
|
||||||
var configFiles embed.FS
|
var configFiles embed.FS
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
BaseDomain string `yaml:"baseDomain"`
|
PangolinVersion string
|
||||||
DashboardDomain string `yaml:"dashboardUrl"`
|
GerbilVersion string
|
||||||
LetsEncryptEmail string `yaml:"letsEncryptEmail"`
|
BaseDomain string
|
||||||
AdminUserEmail string `yaml:"adminUserEmail"`
|
DashboardDomain string
|
||||||
AdminUserPassword string `yaml:"adminUserPassword"`
|
LetsEncryptEmail string
|
||||||
DisableSignupWithoutInvite bool `yaml:"disableSignupWithoutInvite"`
|
AdminUserEmail string
|
||||||
DisableUserCreateOrg bool `yaml:"disableUserCreateOrg"`
|
AdminUserPassword string
|
||||||
EnableEmail bool `yaml:"enableEmail"`
|
DisableSignupWithoutInvite bool
|
||||||
EmailSMTPHost string `yaml:"emailSMTPHost"`
|
DisableUserCreateOrg bool
|
||||||
EmailSMTPPort int `yaml:"emailSMTPPort"`
|
EnableEmail bool
|
||||||
EmailSMTPUser string `yaml:"emailSMTPUser"`
|
EmailSMTPHost string
|
||||||
EmailSMTPPass string `yaml:"emailSMTPPass"`
|
EmailSMTPPort int
|
||||||
EmailNoReply string `yaml:"emailNoReply"`
|
EmailSMTPUser string
|
||||||
|
EmailSMTPPass string
|
||||||
|
EmailNoReply string
|
||||||
|
InstallGerbil bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -45,13 +56,16 @@ func main() {
|
|||||||
// check if there is already a config file
|
// check if there is already a config file
|
||||||
if _, err := os.Stat("config/config.yml"); err != nil {
|
if _, err := os.Stat("config/config.yml"); err != nil {
|
||||||
config := collectUserInput(reader)
|
config := collectUserInput(reader)
|
||||||
|
|
||||||
|
loadVersions(&config)
|
||||||
|
|
||||||
if err := createConfigFiles(config); err != nil {
|
if err := createConfigFiles(config); err != nil {
|
||||||
fmt.Printf("Error creating config files: %v\n", err)
|
fmt.Printf("Error creating config files: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isDockerInstalled() && runtime.GOOS == "linux" {
|
if !isDockerInstalled() && runtime.GOOS == "linux" {
|
||||||
if shouldInstallDocker() {
|
if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
|
||||||
installDocker()
|
installDocker()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,6 +96,24 @@ func readString(reader *bufio.Reader, prompt string, defaultValue string) string
|
|||||||
return input
|
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 readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool {
|
func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool {
|
||||||
defaultStr := "no"
|
defaultStr := "no"
|
||||||
if defaultValue {
|
if defaultValue {
|
||||||
@@ -109,21 +141,29 @@ func collectUserInput(reader *bufio.Reader) Config {
|
|||||||
config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
|
config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
|
||||||
config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", "pangolin."+config.BaseDomain)
|
config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", "pangolin."+config.BaseDomain)
|
||||||
config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "")
|
config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "")
|
||||||
|
config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunned connections", true)
|
||||||
|
|
||||||
// Admin user configuration
|
// Admin user configuration
|
||||||
fmt.Println("\n=== Admin User Configuration ===")
|
fmt.Println("\n=== Admin User Configuration ===")
|
||||||
config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain)
|
config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain)
|
||||||
for {
|
for {
|
||||||
config.AdminUserPassword = readString(reader, "Enter admin user password", "")
|
pass1 := readPassword("Create admin user password")
|
||||||
if valid, message := validatePassword(config.AdminUserPassword); valid {
|
pass2 := readPassword("Confirm admin user password")
|
||||||
break
|
|
||||||
|
if pass1 != pass2 {
|
||||||
|
fmt.Println("Passwords do not match")
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("Invalid password:", message)
|
config.AdminUserPassword = pass1
|
||||||
fmt.Println("Password requirements:")
|
if valid, message := validatePassword(config.AdminUserPassword); valid {
|
||||||
fmt.Println("- At least one uppercase English letter")
|
break
|
||||||
fmt.Println("- At least one lowercase English letter")
|
} else {
|
||||||
fmt.Println("- At least one digit")
|
fmt.Println("Invalid password:", message)
|
||||||
fmt.Println("- At least one special character")
|
fmt.Println("Password requirements:")
|
||||||
|
fmt.Println("- At least one uppercase English letter")
|
||||||
|
fmt.Println("- At least one lowercase English letter")
|
||||||
|
fmt.Println("- At least one digit")
|
||||||
|
fmt.Println("- At least one special character")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,13 +342,6 @@ func createConfigFiles(config Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldInstallDocker() bool {
|
|
||||||
reader := bufio.NewReader(os.Stdin)
|
|
||||||
fmt.Print("Would you like to install Docker? (yes/no): ")
|
|
||||||
response, _ := reader.ReadString('\n')
|
|
||||||
return strings.ToLower(strings.TrimSpace(response)) == "yes"
|
|
||||||
}
|
|
||||||
|
|
||||||
func installDocker() error {
|
func installDocker() error {
|
||||||
// Detect Linux distribution
|
// Detect Linux distribution
|
||||||
cmd := exec.Command("cat", "/etc/os-release")
|
cmd := exec.Command("cat", "/etc/os-release")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@fosrl/pangolin",
|
"name": "@fosrl/pangolin",
|
||||||
"version": "1.0.0-beta.4",
|
"version": "1.0.0-beta.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",
|
"description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
"@oslojs/encoding": "1.1.0",
|
"@oslojs/encoding": "1.1.0",
|
||||||
"@radix-ui/react-avatar": "1.1.2",
|
"@radix-ui/react-avatar": "1.1.2",
|
||||||
"@radix-ui/react-checkbox": "1.1.3",
|
"@radix-ui/react-checkbox": "1.1.3",
|
||||||
|
"@radix-ui/react-collapsible": "1.1.2",
|
||||||
"@radix-ui/react-dialog": "1.1.4",
|
"@radix-ui/react-dialog": "1.1.4",
|
||||||
"@radix-ui/react-dropdown-menu": "2.1.4",
|
"@radix-ui/react-dropdown-menu": "2.1.4",
|
||||||
"@radix-ui/react-icons": "1.3.2",
|
"@radix-ui/react-icons": "1.3.2",
|
||||||
|
|||||||
@@ -20,23 +20,32 @@ const externalPort = config.getRawConfig().server.external_port;
|
|||||||
export function createApiServer() {
|
export function createApiServer() {
|
||||||
const apiServer = express();
|
const apiServer = express();
|
||||||
|
|
||||||
// Middleware setup
|
if (config.getRawConfig().server.trust_proxy) {
|
||||||
apiServer.set("trust proxy", 1);
|
apiServer.set("trust proxy", 1);
|
||||||
if (dev) {
|
}
|
||||||
apiServer.use(
|
|
||||||
cors({
|
|
||||||
origin: `http://localhost:${config.getRawConfig().server.next_port}`,
|
|
||||||
credentials: true
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const corsOptions = {
|
|
||||||
origin: config.getRawConfig().app.dashboard_url,
|
|
||||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
|
|
||||||
allowedHeaders: ["Content-Type", "X-CSRF-Token"]
|
|
||||||
};
|
|
||||||
|
|
||||||
apiServer.use(cors(corsOptions));
|
const corsConfig = config.getRawConfig().server.cors;
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
...(corsConfig?.origins
|
||||||
|
? { origin: corsConfig.origins }
|
||||||
|
: {
|
||||||
|
origin: (origin: any, callback: any) => {
|
||||||
|
callback(null, true);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
...(corsConfig?.methods && { methods: corsConfig.methods }),
|
||||||
|
...(corsConfig?.allowed_headers && {
|
||||||
|
allowedHeaders: corsConfig.allowed_headers
|
||||||
|
}),
|
||||||
|
credentials: !(corsConfig?.credentials === false)
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.debug("Using CORS options", options);
|
||||||
|
|
||||||
|
apiServer.use(cors(options));
|
||||||
|
|
||||||
|
if (!dev) {
|
||||||
apiServer.use(helmet());
|
apiServer.use(helmet());
|
||||||
apiServer.use(csrfProtectionMiddleware);
|
apiServer.use(csrfProtectionMiddleware);
|
||||||
}
|
}
|
||||||
@@ -47,7 +56,8 @@ export function createApiServer() {
|
|||||||
if (!dev) {
|
if (!dev) {
|
||||||
apiServer.use(
|
apiServer.use(
|
||||||
rateLimitMiddleware({
|
rateLimitMiddleware({
|
||||||
windowMin: config.getRawConfig().rate_limits.global.window_minutes,
|
windowMin:
|
||||||
|
config.getRawConfig().rate_limits.global.window_minutes,
|
||||||
max: config.getRawConfig().rate_limits.global.max_requests,
|
max: config.getRawConfig().rate_limits.global.max_requests,
|
||||||
type: "IP_AND_PATH"
|
type: "IP_AND_PATH"
|
||||||
})
|
})
|
||||||
|
|||||||
45
server/auth/canUserAccessResource.ts
Normal file
45
server/auth/canUserAccessResource.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import db from "@server/db";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { roleResources, userResources } from "@server/db/schema";
|
||||||
|
|
||||||
|
export async function canUserAccessResource({
|
||||||
|
userId,
|
||||||
|
resourceId,
|
||||||
|
roleId
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
resourceId: number;
|
||||||
|
roleId: number;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const roleResourceAccess = await db
|
||||||
|
.select()
|
||||||
|
.from(roleResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleResources.resourceId, resourceId),
|
||||||
|
eq(roleResources.roleId, roleId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (roleResourceAccess.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userResourceAccess = await db
|
||||||
|
.select()
|
||||||
|
.from(userResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userResources.userId, userId),
|
||||||
|
eq(userResources.resourceId, resourceId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (userResourceAccess.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
encodeBase32LowerCaseNoPadding,
|
encodeBase32LowerCaseNoPadding,
|
||||||
encodeHexLowerCase,
|
encodeHexLowerCase
|
||||||
} from "@oslojs/encoding";
|
} from "@oslojs/encoding";
|
||||||
import { sha256 } from "@oslojs/crypto/sha2";
|
import { sha256 } from "@oslojs/crypto/sha2";
|
||||||
import { Session, sessions, User, users } from "@server/db/schema";
|
import { Session, sessions, User, users } from "@server/db/schema";
|
||||||
@@ -9,8 +9,10 @@ import { eq } from "drizzle-orm";
|
|||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import type { RandomReader } from "@oslojs/crypto/random";
|
import type { RandomReader } from "@oslojs/crypto/random";
|
||||||
import { generateRandomString } from "@oslojs/crypto/random";
|
import { generateRandomString } from "@oslojs/crypto/random";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
export const SESSION_COOKIE_NAME = config.getRawConfig().server.session_cookie_name;
|
export const SESSION_COOKIE_NAME =
|
||||||
|
config.getRawConfig().server.session_cookie_name;
|
||||||
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
|
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
|
||||||
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
|
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
|
||||||
export const COOKIE_DOMAIN = "." + config.getBaseDomain();
|
export const COOKIE_DOMAIN = "." + config.getBaseDomain();
|
||||||
@@ -24,25 +26,25 @@ export function generateSessionToken(): string {
|
|||||||
|
|
||||||
export async function createSession(
|
export async function createSession(
|
||||||
token: string,
|
token: string,
|
||||||
userId: string,
|
userId: string
|
||||||
): Promise<Session> {
|
): Promise<Session> {
|
||||||
const sessionId = encodeHexLowerCase(
|
const sessionId = encodeHexLowerCase(
|
||||||
sha256(new TextEncoder().encode(token)),
|
sha256(new TextEncoder().encode(token))
|
||||||
);
|
);
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
userId,
|
userId,
|
||||||
expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(),
|
expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime()
|
||||||
};
|
};
|
||||||
await db.insert(sessions).values(session);
|
await db.insert(sessions).values(session);
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateSessionToken(
|
export async function validateSessionToken(
|
||||||
token: string,
|
token: string
|
||||||
): Promise<SessionValidationResult> {
|
): Promise<SessionValidationResult> {
|
||||||
const sessionId = encodeHexLowerCase(
|
const sessionId = encodeHexLowerCase(
|
||||||
sha256(new TextEncoder().encode(token)),
|
sha256(new TextEncoder().encode(token))
|
||||||
);
|
);
|
||||||
const result = await db
|
const result = await db
|
||||||
.select({ user: users, session: sessions })
|
.select({ user: users, session: sessions })
|
||||||
@@ -61,12 +63,12 @@ export async function validateSessionToken(
|
|||||||
}
|
}
|
||||||
if (Date.now() >= session.expiresAt - SESSION_COOKIE_EXPIRES / 2) {
|
if (Date.now() >= session.expiresAt - SESSION_COOKIE_EXPIRES / 2) {
|
||||||
session.expiresAt = new Date(
|
session.expiresAt = new Date(
|
||||||
Date.now() + SESSION_COOKIE_EXPIRES,
|
Date.now() + SESSION_COOKIE_EXPIRES
|
||||||
).getTime();
|
).getTime();
|
||||||
await db
|
await db
|
||||||
.update(sessions)
|
.update(sessions)
|
||||||
.set({
|
.set({
|
||||||
expiresAt: session.expiresAt,
|
expiresAt: session.expiresAt
|
||||||
})
|
})
|
||||||
.where(eq(sessions.sessionId, session.sessionId));
|
.where(eq(sessions.sessionId, session.sessionId));
|
||||||
}
|
}
|
||||||
@@ -81,26 +83,38 @@ export async function invalidateAllSessions(userId: string): Promise<void> {
|
|||||||
await db.delete(sessions).where(eq(sessions.userId, userId));
|
await db.delete(sessions).where(eq(sessions.userId, userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeSessionCookie(token: string): string {
|
export function serializeSessionCookie(
|
||||||
if (SECURE_COOKIES) {
|
token: string,
|
||||||
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
isSecure: boolean
|
||||||
|
): string {
|
||||||
|
if (isSecure) {
|
||||||
|
logger.debug("Setting cookie for secure origin");
|
||||||
|
if (SECURE_COOKIES) {
|
||||||
|
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
||||||
|
} else {
|
||||||
|
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
|
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBlankSessionTokenCookie(): string {
|
export function createBlankSessionTokenCookie(isSecure: boolean): string {
|
||||||
if (SECURE_COOKIES) {
|
if (isSecure) {
|
||||||
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
if (SECURE_COOKIES) {
|
||||||
|
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
||||||
|
} else {
|
||||||
|
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
|
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/;`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const random: RandomReader = {
|
const random: RandomReader = {
|
||||||
read(bytes: Uint8Array): void {
|
read(bytes: Uint8Array): void {
|
||||||
crypto.getRandomValues(bytes);
|
crypto.getRandomValues(bytes);
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export function generateId(length: number): string {
|
export function generateId(length: number): string {
|
||||||
|
|||||||
67
server/auth/verifyResourceAccessToken.ts
Normal file
67
server/auth/verifyResourceAccessToken.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import db from "@server/db";
|
||||||
|
import {
|
||||||
|
Resource,
|
||||||
|
ResourceAccessToken,
|
||||||
|
resourceAccessToken,
|
||||||
|
} from "@server/db/schema";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { isWithinExpirationDate } from "oslo";
|
||||||
|
import { verifyPassword } from "./password";
|
||||||
|
|
||||||
|
export async function verifyResourceAccessToken({
|
||||||
|
resource,
|
||||||
|
accessTokenId,
|
||||||
|
accessToken
|
||||||
|
}: {
|
||||||
|
resource: Resource;
|
||||||
|
accessTokenId: string;
|
||||||
|
accessToken: string;
|
||||||
|
}): Promise<{
|
||||||
|
valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
tokenItem?: ResourceAccessToken;
|
||||||
|
}> {
|
||||||
|
const [result] = await db
|
||||||
|
.select()
|
||||||
|
.from(resourceAccessToken)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceAccessToken.resourceId, resource.resourceId),
|
||||||
|
eq(resourceAccessToken.accessTokenId, accessTokenId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const tokenItem = result;
|
||||||
|
|
||||||
|
if (!tokenItem) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "Access token does not exist for resource"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const validCode = await verifyPassword(accessToken, tokenItem.tokenHash);
|
||||||
|
|
||||||
|
if (!validCode) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "Invalid access token"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
tokenItem.expiresAt &&
|
||||||
|
!isWithinExpirationDate(new Date(tokenItem.expiresAt))
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "Access token has expired"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
tokenItem
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,10 +4,13 @@ import * as schema from "@server/db/schema";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import { APP_PATH } from "@server/lib/consts";
|
import { APP_PATH } from "@server/lib/consts";
|
||||||
|
import { existsSync, mkdirSync } from "fs";
|
||||||
|
|
||||||
export const location = path.join(APP_PATH, "db", "db.sqlite");
|
export const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||||
export const exists = await checkFileExists(location);
|
export const exists = await checkFileExists(location);
|
||||||
|
|
||||||
|
bootstrapVolume();
|
||||||
|
|
||||||
const sqlite = new Database(location);
|
const sqlite = new Database(location);
|
||||||
export const db = drizzle(sqlite, { schema });
|
export const db = drizzle(sqlite, { schema });
|
||||||
|
|
||||||
@@ -21,3 +24,29 @@ async function checkFileExists(filePath: string): Promise<boolean> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bootstrapVolume() {
|
||||||
|
const appPath = APP_PATH;
|
||||||
|
|
||||||
|
const dbDir = path.join(appPath, "db");
|
||||||
|
const logsDir = path.join(appPath, "logs");
|
||||||
|
|
||||||
|
// check if the db directory exists and create it if it doesn't
|
||||||
|
if (!existsSync(dbDir)) {
|
||||||
|
mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the logs directory exists and create it if it doesn't
|
||||||
|
if (!existsSync(logsDir)) {
|
||||||
|
mkdirSync(logsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// THIS IS FOR TRAEFIK; NOT REALLY NEEDED, BUT JUST IN CASE
|
||||||
|
|
||||||
|
const traefikDir = path.join(appPath, "traefik");
|
||||||
|
|
||||||
|
// check if the traefik directory exists and create it if it doesn't
|
||||||
|
if (!existsSync(traefikDir)) {
|
||||||
|
mkdirSync(traefikDir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,15 @@ import yaml from "js-yaml";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { __DIRNAME, APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
|
import {
|
||||||
|
__DIRNAME,
|
||||||
|
APP_PATH,
|
||||||
|
configFilePath1,
|
||||||
|
configFilePath2
|
||||||
|
} from "@server/lib/consts";
|
||||||
import { loadAppVersion } from "@server/lib/loadAppVersion";
|
import { loadAppVersion } from "@server/lib/loadAppVersion";
|
||||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
|
import stoi from "./stoi";
|
||||||
|
|
||||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||||
const hostnameSchema = z
|
const hostnameSchema = z
|
||||||
@@ -15,24 +21,56 @@ const hostnameSchema = z
|
|||||||
)
|
)
|
||||||
.or(z.literal("localhost"));
|
.or(z.literal("localhost"));
|
||||||
|
|
||||||
const environmentSchema = z.object({
|
const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
|
||||||
|
return process.env[envVar] ?? valFromYaml;
|
||||||
|
};
|
||||||
|
|
||||||
|
const configSchema = z.object({
|
||||||
app: z.object({
|
app: z.object({
|
||||||
dashboard_url: z
|
dashboard_url: z
|
||||||
.string()
|
.string()
|
||||||
.url()
|
.url()
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("APP_DASHBOARDURL"))
|
||||||
|
.pipe(z.string().url())
|
||||||
.transform((url) => url.toLowerCase()),
|
.transform((url) => url.toLowerCase()),
|
||||||
base_domain: hostnameSchema,
|
base_domain: hostnameSchema
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("APP_BASEDOMAIN"))
|
||||||
|
.pipe(hostnameSchema),
|
||||||
log_level: z.enum(["debug", "info", "warn", "error"]),
|
log_level: z.enum(["debug", "info", "warn", "error"]),
|
||||||
save_logs: z.boolean()
|
save_logs: z.boolean()
|
||||||
}),
|
}),
|
||||||
server: z.object({
|
server: z.object({
|
||||||
external_port: portSchema,
|
external_port: portSchema
|
||||||
internal_port: portSchema,
|
.optional()
|
||||||
next_port: portSchema,
|
.transform(getEnvOrYaml("SERVER_EXTERNALPORT"))
|
||||||
|
.transform(stoi)
|
||||||
|
.pipe(portSchema),
|
||||||
|
internal_port: portSchema
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("SERVER_INTERNALPORT"))
|
||||||
|
.transform(stoi)
|
||||||
|
.pipe(portSchema),
|
||||||
|
next_port: portSchema
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("SERVER_NEXTPORT"))
|
||||||
|
.transform(stoi)
|
||||||
|
.pipe(portSchema),
|
||||||
internal_hostname: z.string().transform((url) => url.toLowerCase()),
|
internal_hostname: z.string().transform((url) => url.toLowerCase()),
|
||||||
secure_cookies: z.boolean(),
|
secure_cookies: z.boolean(),
|
||||||
session_cookie_name: z.string(),
|
session_cookie_name: z.string(),
|
||||||
resource_session_cookie_name: z.string()
|
resource_session_cookie_name: z.string(),
|
||||||
|
resource_access_token_param: z.string(),
|
||||||
|
cors: z
|
||||||
|
.object({
|
||||||
|
origins: z.array(z.string()).optional(),
|
||||||
|
methods: z.array(z.string()).optional(),
|
||||||
|
allowed_headers: z.array(z.string()).optional(),
|
||||||
|
credentials: z.boolean().optional()
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
trust_proxy: z.boolean().optional().default(true)
|
||||||
}),
|
}),
|
||||||
traefik: z.object({
|
traefik: z.object({
|
||||||
http_entrypoint: z.string(),
|
http_entrypoint: z.string(),
|
||||||
@@ -41,8 +79,17 @@ const environmentSchema = z.object({
|
|||||||
prefer_wildcard_cert: z.boolean().optional()
|
prefer_wildcard_cert: z.boolean().optional()
|
||||||
}),
|
}),
|
||||||
gerbil: z.object({
|
gerbil: z.object({
|
||||||
start_port: portSchema,
|
start_port: portSchema
|
||||||
base_endpoint: z.string().transform((url) => url.toLowerCase()),
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("GERBIL_STARTPORT"))
|
||||||
|
.transform(stoi)
|
||||||
|
.pipe(portSchema),
|
||||||
|
base_endpoint: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("GERBIL_BASEENDPOINT"))
|
||||||
|
.pipe(z.string())
|
||||||
|
.transform((url) => url.toLowerCase()),
|
||||||
use_subdomain: z.boolean(),
|
use_subdomain: z.boolean(),
|
||||||
subnet_group: z.string(),
|
subnet_group: z.string(),
|
||||||
block_size: z.number().positive().gt(0),
|
block_size: z.number().positive().gt(0),
|
||||||
@@ -71,8 +118,16 @@ const environmentSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
users: z.object({
|
users: z.object({
|
||||||
server_admin: z.object({
|
server_admin: z.object({
|
||||||
email: z.string().email(),
|
email: z
|
||||||
|
.string()
|
||||||
|
.email()
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("USERS_SERVERADMIN_EMAIL"))
|
||||||
|
.pipe(z.string().email()),
|
||||||
password: passwordSchema
|
password: passwordSchema
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("USERS_SERVERADMIN_PASSWORD"))
|
||||||
|
.pipe(passwordSchema)
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
flags: z
|
flags: z
|
||||||
@@ -85,12 +140,18 @@ const environmentSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export class Config {
|
export class Config {
|
||||||
private rawConfig!: z.infer<typeof environmentSchema>;
|
private rawConfig!: z.infer<typeof configSchema>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.loadConfig();
|
this.loadConfig();
|
||||||
|
|
||||||
|
if (process.env.GENERATE_TRAEFIK_CONFIG === "true") {
|
||||||
|
this.createTraefikConfig();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public loadEnvironment() {}
|
||||||
|
|
||||||
public loadConfig() {
|
public loadConfig() {
|
||||||
const loadConfig = (configPath: string) => {
|
const loadConfig = (configPath: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -131,6 +192,9 @@ export class Config {
|
|||||||
);
|
);
|
||||||
environment = loadConfig(configFilePath1);
|
environment = loadConfig(configFilePath1);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.log(
|
||||||
|
"See the docs for information about what to include in the configuration file: https://docs.fossorial.io/Pangolin/Configuration/config"
|
||||||
|
);
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Error creating configuration file from example: ${
|
`Error creating configuration file from example: ${
|
||||||
@@ -151,7 +215,7 @@ export class Config {
|
|||||||
throw new Error("No configuration file found");
|
throw new Error("No configuration file found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedConfig = environmentSchema.safeParse(environment);
|
const parsedConfig = configSchema.safeParse(environment);
|
||||||
|
|
||||||
if (!parsedConfig.success) {
|
if (!parsedConfig.success) {
|
||||||
const errors = fromError(parsedConfig.error);
|
const errors = fromError(parsedConfig.error);
|
||||||
@@ -186,6 +250,8 @@ export class Config {
|
|||||||
?.disable_user_create_org
|
?.disable_user_create_org
|
||||||
? "true"
|
? "true"
|
||||||
: "false";
|
: "false";
|
||||||
|
process.env.RESOURCE_ACCESS_TOKEN_PARAM =
|
||||||
|
parsedConfig.data.server.resource_access_token_param;
|
||||||
|
|
||||||
this.rawConfig = parsedConfig.data;
|
this.rawConfig = parsedConfig.data;
|
||||||
}
|
}
|
||||||
@@ -197,6 +263,72 @@ export class Config {
|
|||||||
public getBaseDomain(): string {
|
public getBaseDomain(): string {
|
||||||
return this.rawConfig.app.base_domain;
|
return this.rawConfig.app.base_domain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createTraefikConfig() {
|
||||||
|
try {
|
||||||
|
// check if traefik_config.yml and dynamic_config.yml exists in APP_PATH/traefik
|
||||||
|
const defaultTraefikConfigPath = path.join(
|
||||||
|
__DIRNAME,
|
||||||
|
"traefik_config.example.yml"
|
||||||
|
);
|
||||||
|
const defaultDynamicConfigPath = path.join(
|
||||||
|
__DIRNAME,
|
||||||
|
"dynamic_config.example.yml"
|
||||||
|
);
|
||||||
|
|
||||||
|
const traefikPath = path.join(APP_PATH, "traefik");
|
||||||
|
if (!fs.existsSync(traefikPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// load default configs
|
||||||
|
let traefikConfig = fs.readFileSync(
|
||||||
|
defaultTraefikConfigPath,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
let dynamicConfig = fs.readFileSync(
|
||||||
|
defaultDynamicConfigPath,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
traefikConfig = traefikConfig
|
||||||
|
.split("{{.LetsEncryptEmail}}")
|
||||||
|
.join(this.rawConfig.users.server_admin.email);
|
||||||
|
traefikConfig = traefikConfig
|
||||||
|
.split("{{.INTERNAL_PORT}}")
|
||||||
|
.join(this.rawConfig.server.internal_port.toString());
|
||||||
|
|
||||||
|
dynamicConfig = dynamicConfig
|
||||||
|
.split("{{.DashboardDomain}}")
|
||||||
|
.join(new URL(this.rawConfig.app.dashboard_url).hostname);
|
||||||
|
dynamicConfig = dynamicConfig
|
||||||
|
.split("{{.NEXT_PORT}}")
|
||||||
|
.join(this.rawConfig.server.next_port.toString());
|
||||||
|
dynamicConfig = dynamicConfig
|
||||||
|
.split("{{.EXTERNAL_PORT}}")
|
||||||
|
.join(this.rawConfig.server.external_port.toString());
|
||||||
|
|
||||||
|
// write thiese to the traefik directory
|
||||||
|
const traefikConfigPath = path.join(
|
||||||
|
traefikPath,
|
||||||
|
"traefik_config.yml"
|
||||||
|
);
|
||||||
|
const dynamicConfigPath = path.join(
|
||||||
|
traefikPath,
|
||||||
|
"dynamic_config.yml"
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(traefikConfigPath, traefikConfig, "utf8");
|
||||||
|
fs.writeFileSync(dynamicConfigPath, dynamicConfig, "utf8");
|
||||||
|
|
||||||
|
console.log("Traefik configuration files created");
|
||||||
|
} catch (e) {
|
||||||
|
console.log(
|
||||||
|
"Failed to generate the Traefik configuration files. Please create them manually."
|
||||||
|
);
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = new Config();
|
export const config = new Config();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { resourceAccessToken, resources, userOrgs } from "@server/db/schema";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { canUserAccessResource } from "@server/lib/canUserAccessResource";
|
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
|
||||||
|
|
||||||
export async function verifyAccessTokenAccess(
|
export async function verifyAccessTokenAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { resources, targets, userOrgs } from "@server/db/schema";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { canUserAccessResource } from "../lib/canUserAccessResource";
|
import { canUserAccessResource } from "../auth/canUserAccessResource";
|
||||||
|
|
||||||
export async function verifyTargetAccess(
|
export async function verifyTargetAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
|
|||||||
@@ -120,7 +120,8 @@ export async function login(
|
|||||||
|
|
||||||
const token = generateSessionToken();
|
const token = generateSessionToken();
|
||||||
await createSession(token, existingUser.userId);
|
await createSession(token, existingUser.userId);
|
||||||
const cookie = serializeSessionCookie(token);
|
const isSecure = req.protocol === "https";
|
||||||
|
const cookie = serializeSessionCookie(token, isSecure);
|
||||||
|
|
||||||
res.appendHeader("Set-Cookie", cookie);
|
res.appendHeader("Set-Cookie", cookie);
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ export async function logout(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await invalidateSession(sessionId);
|
await invalidateSession(sessionId);
|
||||||
res.setHeader("Set-Cookie", createBlankSessionTokenCookie());
|
const isSecure = req.protocol === "https";
|
||||||
|
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
|
||||||
|
|
||||||
return response<null>(res, {
|
return response<null>(res, {
|
||||||
data: null,
|
data: null,
|
||||||
|
|||||||
@@ -158,7 +158,8 @@ export async function signup(
|
|||||||
|
|
||||||
const token = generateSessionToken();
|
const token = generateSessionToken();
|
||||||
await createSession(token, userId);
|
await createSession(token, userId);
|
||||||
const cookie = serializeSessionCookie(token);
|
const isSecure = req.protocol === "https";
|
||||||
|
const cookie = serializeSessionCookie(token, isSecure);
|
||||||
res.appendHeader("Set-Cookie", cookie);
|
res.appendHeader("Set-Cookie", cookie);
|
||||||
|
|
||||||
if (config.getRawConfig().flags?.require_email_verification) {
|
if (config.getRawConfig().flags?.require_email_verification) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { response } from "@server/lib/response";
|
|||||||
import { validateSessionToken } from "@server/auth/sessions/app";
|
import { validateSessionToken } from "@server/auth/sessions/app";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import {
|
import {
|
||||||
|
ResourceAccessToken,
|
||||||
resourceAccessToken,
|
resourceAccessToken,
|
||||||
resourcePassword,
|
resourcePassword,
|
||||||
resourcePincode,
|
resourcePincode,
|
||||||
@@ -17,9 +18,15 @@ import {
|
|||||||
} from "@server/db/schema";
|
} from "@server/db/schema";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { validateResourceSessionToken } from "@server/auth/sessions/resource";
|
import {
|
||||||
|
createResourceSession,
|
||||||
|
serializeResourceSessionCookie,
|
||||||
|
validateResourceSessionToken
|
||||||
|
} from "@server/auth/sessions/resource";
|
||||||
import { Resource, roleResources, userResources } from "@server/db/schema";
|
import { Resource, roleResources, userResources } from "@server/db/schema";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||||
|
import { generateSessionToken } from "@server/auth";
|
||||||
|
|
||||||
const verifyResourceSessionSchema = z.object({
|
const verifyResourceSessionSchema = z.object({
|
||||||
sessions: z.record(z.string()).optional(),
|
sessions: z.record(z.string()).optional(),
|
||||||
@@ -28,6 +35,7 @@ const verifyResourceSessionSchema = z.object({
|
|||||||
host: z.string(),
|
host: z.string(),
|
||||||
path: z.string(),
|
path: z.string(),
|
||||||
method: z.string(),
|
method: z.string(),
|
||||||
|
accessToken: z.string().optional(),
|
||||||
tls: z.boolean()
|
tls: z.boolean()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,7 +67,8 @@ export async function verifyResourceSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { sessions, host, originalRequestURL } = parsedBody.data;
|
const { sessions, host, originalRequestURL, accessToken: token } =
|
||||||
|
parsedBody.data;
|
||||||
|
|
||||||
const [result] = await db
|
const [result] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -103,11 +112,41 @@ export async function verifyResourceSession(
|
|||||||
|
|
||||||
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
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logger.debug("Access token invalid: " + error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valid && tokenItem) {
|
||||||
|
validAccessToken = tokenItem;
|
||||||
|
|
||||||
|
if (!sessions) {
|
||||||
|
return await createAccessTokenSession(
|
||||||
|
res,
|
||||||
|
resource,
|
||||||
|
tokenItem
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!sessions) {
|
if (!sessions) {
|
||||||
return notAllowed(res);
|
return notAllowed(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionToken = sessions[config.getRawConfig().server.session_cookie_name];
|
const sessionToken =
|
||||||
|
sessions[config.getRawConfig().server.session_cookie_name];
|
||||||
|
|
||||||
// check for unified login
|
// check for unified login
|
||||||
if (sso && sessionToken) {
|
if (sso && sessionToken) {
|
||||||
@@ -172,6 +211,16 @@ 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.
|
||||||
|
if (validAccessToken) {
|
||||||
|
return await createAccessTokenSession(
|
||||||
|
res,
|
||||||
|
resource,
|
||||||
|
validAccessToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug("No more auth to check, resource not allowed");
|
logger.debug("No more auth to check, resource not allowed");
|
||||||
return notAllowed(res, redirectUrl);
|
return notAllowed(res, redirectUrl);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -209,11 +258,41 @@ function allowed(res: Response) {
|
|||||||
return response<VerifyUserResponse>(res, data);
|
return response<VerifyUserResponse>(res, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createAccessTokenSession(
|
||||||
|
res: Response,
|
||||||
|
resource: Resource,
|
||||||
|
tokenItem: ResourceAccessToken
|
||||||
|
) {
|
||||||
|
const token = generateSessionToken();
|
||||||
|
await createResourceSession({
|
||||||
|
resourceId: resource.resourceId,
|
||||||
|
token,
|
||||||
|
accessTokenId: tokenItem.accessTokenId,
|
||||||
|
sessionLength: tokenItem.sessionLength,
|
||||||
|
expiresAt: tokenItem.expiresAt,
|
||||||
|
doNotExtend: tokenItem.expiresAt ? true : false
|
||||||
|
});
|
||||||
|
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
|
||||||
|
const cookie = serializeResourceSessionCookie(cookieName, token);
|
||||||
|
res.appendHeader("Set-Cookie", cookie);
|
||||||
|
logger.debug("Access token is valid, creating new session")
|
||||||
|
return response<VerifyUserResponse>(res, {
|
||||||
|
data: { valid: true },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Access allowed",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function isUserAllowedToAccessResource(
|
async function isUserAllowedToAccessResource(
|
||||||
user: User,
|
user: User,
|
||||||
resource: Resource
|
resource: Resource
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (config.getRawConfig().flags?.require_email_verification && !user.emailVerified) {
|
if (
|
||||||
|
config.getRawConfig().flags?.require_email_verification &&
|
||||||
|
!user.emailVerified
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ export async function getConfig(req: Request, res: Response, next: NextFunction)
|
|||||||
let exitNode;
|
let exitNode;
|
||||||
if (exitNodeQuery.length === 0) {
|
if (exitNodeQuery.length === 0) {
|
||||||
const address = await getNextAvailableSubnet();
|
const address = await getNextAvailableSubnet();
|
||||||
const listenPort = await getNextAvailablePort();
|
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
||||||
|
// const listenPort = await getNextAvailablePort();
|
||||||
|
const listenPort = config.getRawConfig().gerbil.start_port;
|
||||||
let subEndpoint = "";
|
let subEndpoint = "";
|
||||||
if (config.getRawConfig().gerbil.use_subdomain) {
|
if (config.getRawConfig().gerbil.use_subdomain) {
|
||||||
subEndpoint = await getUniqueExitNodeEndpointName();
|
subEndpoint = await getUniqueExitNodeEndpointName();
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ import {
|
|||||||
} from "@server/auth/sessions/resource";
|
} from "@server/auth/sessions/resource";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { verify } from "@node-rs/argon2";
|
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||||
import { isWithinExpirationDate } from "oslo";
|
|
||||||
import { verifyPassword } from "@server/auth/password";
|
|
||||||
|
|
||||||
const authWithAccessTokenBodySchema = z
|
const authWithAccessTokenBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -69,58 +67,38 @@ export async function authWithAccessToken(
|
|||||||
const { accessToken, accessTokenId } = parsedBody.data;
|
const { accessToken, accessTokenId } = parsedBody.data;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [result] = await db
|
const [resource] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(resourceAccessToken)
|
.from(resources)
|
||||||
.where(
|
.where(eq(resources.resourceId, resourceId))
|
||||||
and(
|
|
||||||
eq(resourceAccessToken.resourceId, resourceId),
|
|
||||||
eq(resourceAccessToken.accessTokenId, accessTokenId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
resources,
|
|
||||||
eq(resources.resourceId, resourceAccessToken.resourceId)
|
|
||||||
)
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
const resource = result?.resources;
|
|
||||||
const tokenItem = result?.resourceAccessToken;
|
|
||||||
|
|
||||||
if (!tokenItem) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.UNAUTHORIZED,
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Access token does not exist for resource"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resource) {
|
if (!resource) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist")
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const validCode = await verifyPassword(accessToken, tokenItem.tokenHash);
|
const { valid, error, tokenItem } = await verifyResourceAccessToken({
|
||||||
|
resource,
|
||||||
|
accessTokenId,
|
||||||
|
accessToken
|
||||||
|
});
|
||||||
|
|
||||||
if (!validCode) {
|
if (!valid) {
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.UNAUTHORIZED, "Invalid access token")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
tokenItem.expiresAt &&
|
|
||||||
!isWithinExpirationDate(new Date(tokenItem.expiresAt))
|
|
||||||
) {
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.UNAUTHORIZED,
|
HttpCode.UNAUTHORIZED,
|
||||||
"Access token has expired"
|
error || "Invalid access token"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tokenItem || !resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.UNAUTHORIZED,
|
||||||
|
"Access token does not exist for resource"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,15 +109,12 @@ export async function authWithPincode(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.UNAUTHORIZED,
|
HttpCode.UNAUTHORIZED,
|
||||||
createHttpError(
|
"Resource has no pincode protection"
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Resource has no pincode protection"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const validPincode = verifyPassword(
|
const validPincode = await verifyPassword(
|
||||||
pincode,
|
pincode,
|
||||||
definedPincode.pincodeHash
|
definedPincode.pincodeHash
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const createSiteParamsSchema = z
|
|||||||
const createSiteSchema = z
|
const createSiteSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
exitNodeId: z.number().int().positive(),
|
exitNodeId: z.number().int().positive().optional(),
|
||||||
// subdomain: z
|
// subdomain: z
|
||||||
// .string()
|
// .string()
|
||||||
// .min(1)
|
// .min(1)
|
||||||
@@ -32,7 +32,7 @@ const createSiteSchema = z
|
|||||||
// .transform((val) => val.toLowerCase())
|
// .transform((val) => val.toLowerCase())
|
||||||
// .optional(),
|
// .optional(),
|
||||||
pubKey: z.string().optional(),
|
pubKey: z.string().optional(),
|
||||||
subnet: z.string(),
|
subnet: z.string().optional(),
|
||||||
newtId: z.string().optional(),
|
newtId: z.string().optional(),
|
||||||
secret: z.string().optional(),
|
secret: z.string().optional(),
|
||||||
type: z.string()
|
type: z.string()
|
||||||
@@ -82,28 +82,46 @@ export async function createSite(
|
|||||||
|
|
||||||
const niceId = await getUniqueSiteName(orgId);
|
const niceId = await getUniqueSiteName(orgId);
|
||||||
|
|
||||||
let payload: any = {
|
|
||||||
orgId,
|
|
||||||
exitNodeId,
|
|
||||||
name,
|
|
||||||
niceId,
|
|
||||||
subnet,
|
|
||||||
type
|
|
||||||
};
|
|
||||||
|
|
||||||
if (pubKey && type == "wireguard") {
|
|
||||||
// we dont add the pubKey for newts because the newt will generate it
|
|
||||||
payload = {
|
|
||||||
...payload,
|
|
||||||
pubKey
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
const [newSite] = await trx
|
let newSite: Site;
|
||||||
.insert(sites)
|
|
||||||
.values(payload)
|
if (exitNodeId) {
|
||||||
.returning();
|
// we are creating a site with an exit node (tunneled)
|
||||||
|
if (!subnet) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Subnet is required for tunneled sites"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[newSite] = await trx
|
||||||
|
.insert(sites)
|
||||||
|
.values({
|
||||||
|
orgId,
|
||||||
|
exitNodeId,
|
||||||
|
name,
|
||||||
|
niceId,
|
||||||
|
subnet,
|
||||||
|
type,
|
||||||
|
...(pubKey && type == "wireguard" && { pubKey })
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
} else {
|
||||||
|
// we are creating a site with no tunneling
|
||||||
|
|
||||||
|
[newSite] = await trx
|
||||||
|
.insert(sites)
|
||||||
|
.values({
|
||||||
|
orgId,
|
||||||
|
name,
|
||||||
|
niceId,
|
||||||
|
type,
|
||||||
|
subnet: "0.0.0.0/0"
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
const adminRole = await trx
|
const adminRole = await trx
|
||||||
.select()
|
.select()
|
||||||
@@ -149,6 +167,16 @@ export async function createSite(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!exitNodeId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Exit node ID is required for wireguard sites"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await addPeer(exitNodeId, {
|
await addPeer(exitNodeId, {
|
||||||
publicKey: pubKey,
|
publicKey: pubKey,
|
||||||
allowedIps: []
|
allowedIps: []
|
||||||
|
|||||||
@@ -123,88 +123,100 @@ export async function createTarget(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// make sure the target is within the site subnet
|
let newTarget: Target[] = [];
|
||||||
if (
|
if (site.type == "local") {
|
||||||
site.type == "wireguard" &&
|
newTarget = await db
|
||||||
!isIpInCidr(targetData.ip, site.subnet!)
|
.insert(targets)
|
||||||
) {
|
.values({
|
||||||
return next(
|
resourceId,
|
||||||
createHttpError(
|
protocol: "tcp", // hard code for now
|
||||||
HttpCode.BAD_REQUEST,
|
...targetData
|
||||||
`Target IP is not within the site subnet`
|
})
|
||||||
)
|
.returning();
|
||||||
);
|
} else {
|
||||||
}
|
// make sure the target is within the site subnet
|
||||||
|
if (
|
||||||
// Fetch resources for this site
|
site.type == "wireguard" &&
|
||||||
const resourcesRes = await db.query.resources.findMany({
|
!isIpInCidr(targetData.ip, site.subnet!)
|
||||||
where: eq(resources.siteId, site.siteId)
|
) {
|
||||||
});
|
return next(
|
||||||
|
createHttpError(
|
||||||
// TODO: is this all inefficient?
|
HttpCode.BAD_REQUEST,
|
||||||
// Fetch targets for all resources of this site
|
`Target IP is not within the site subnet`
|
||||||
let targetIps: string[] = [];
|
)
|
||||||
let targetInternalPorts: number[] = [];
|
);
|
||||||
await Promise.all(
|
|
||||||
resourcesRes.map(async (resource) => {
|
|
||||||
const targetsRes = await db.query.targets.findMany({
|
|
||||||
where: eq(targets.resourceId, resource.resourceId)
|
|
||||||
});
|
|
||||||
targetsRes.forEach((target) => {
|
|
||||||
targetIps.push(`${target.ip}/32`);
|
|
||||||
if (target.internalPort) {
|
|
||||||
targetInternalPorts.push(target.internalPort);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
let internalPort!: number;
|
|
||||||
// pick a port
|
|
||||||
for (let i = 40000; i < 65535; i++) {
|
|
||||||
if (!targetInternalPorts.includes(i)) {
|
|
||||||
internalPort = i;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!internalPort) {
|
// Fetch resources for this site
|
||||||
return next(
|
const resourcesRes = await db.query.resources.findMany({
|
||||||
createHttpError(
|
where: eq(resources.siteId, site.siteId)
|
||||||
HttpCode.BAD_REQUEST,
|
});
|
||||||
`No available internal port`
|
|
||||||
)
|
// TODO: is this all inefficient?
|
||||||
|
// Fetch targets for all resources of this site
|
||||||
|
let targetIps: string[] = [];
|
||||||
|
let targetInternalPorts: number[] = [];
|
||||||
|
await Promise.all(
|
||||||
|
resourcesRes.map(async (resource) => {
|
||||||
|
const targetsRes = await db.query.targets.findMany({
|
||||||
|
where: eq(targets.resourceId, resource.resourceId)
|
||||||
|
});
|
||||||
|
targetsRes.forEach((target) => {
|
||||||
|
targetIps.push(`${target.ip}/32`);
|
||||||
|
if (target.internalPort) {
|
||||||
|
targetInternalPorts.push(target.internalPort);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const newTarget = await db
|
let internalPort!: number;
|
||||||
.insert(targets)
|
// pick a port
|
||||||
.values({
|
for (let i = 40000; i < 65535; i++) {
|
||||||
resourceId,
|
if (!targetInternalPorts.includes(i)) {
|
||||||
protocol: "tcp", // hard code for now
|
internalPort = i;
|
||||||
internalPort,
|
break;
|
||||||
...targetData
|
}
|
||||||
})
|
}
|
||||||
.returning();
|
|
||||||
|
|
||||||
// add the new target to the targetIps array
|
if (!internalPort) {
|
||||||
targetIps.push(`${targetData.ip}/32`);
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
`No available internal port`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (site.pubKey) {
|
newTarget = await db
|
||||||
if (site.type == "wireguard") {
|
.insert(targets)
|
||||||
await addPeer(site.exitNodeId!, {
|
.values({
|
||||||
publicKey: site.pubKey,
|
resourceId,
|
||||||
allowedIps: targetIps.flat()
|
protocol: "tcp", // hard code for now
|
||||||
});
|
internalPort,
|
||||||
} else if (site.type == "newt") {
|
...targetData
|
||||||
// get the newt on the site by querying the newt table for siteId
|
})
|
||||||
const [newt] = await db
|
.returning();
|
||||||
.select()
|
|
||||||
.from(newts)
|
|
||||||
.where(eq(newts.siteId, site.siteId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
addTargets(newt.newtId, newTarget);
|
// add the new target to the targetIps array
|
||||||
|
targetIps.push(`${targetData.ip}/32`);
|
||||||
|
|
||||||
|
if (site.pubKey) {
|
||||||
|
if (site.type == "wireguard") {
|
||||||
|
await addPeer(site.exitNodeId!, {
|
||||||
|
publicKey: site.pubKey,
|
||||||
|
allowedIps: targetIps.flat()
|
||||||
|
});
|
||||||
|
} else if (site.type == "newt") {
|
||||||
|
// get the newt on the site by querying the newt table for siteId
|
||||||
|
const [newt] = await db
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.siteId, site.siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
addTargets(newt.newtId, newTarget);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export async function traefikConfigProvider(
|
|||||||
config.getRawConfig().server.resource_session_cookie_name,
|
config.getRawConfig().server.resource_session_cookie_name,
|
||||||
userSessionCookieName:
|
userSessionCookieName:
|
||||||
config.getRawConfig().server.session_cookie_name,
|
config.getRawConfig().server.session_cookie_name,
|
||||||
|
accessTokenQueryParam: config.getRawConfig().server.resource_access_token_param,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -150,6 +151,16 @@ export async function traefikConfigProvider(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
} else if (site.type === "local") {
|
||||||
|
http.services![serviceName] = {
|
||||||
|
loadBalancer: {
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
url: `${target.method}://${target.ip}:${target.port}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import logger from "@server/logger";
|
|||||||
export async function copyInConfig() {
|
export async function copyInConfig() {
|
||||||
const domain = config.getBaseDomain();
|
const domain = config.getBaseDomain();
|
||||||
const endpoint = config.getRawConfig().gerbil.base_endpoint;
|
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
|
// 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
|
// TODO: eventually each org could have a unique domain that we do not want to overwrite, so this will be unnecessary
|
||||||
@@ -14,6 +15,8 @@ export async function copyInConfig() {
|
|||||||
|
|
||||||
// TODO: eventually each exit node could have a different endpoint
|
// TODO: eventually each exit node could have a different endpoint
|
||||||
await db.update(exitNodes).set({ endpoint }).where(ne(exitNodes.endpoint, 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
|
// update all resources fullDomain to use the new domain
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import path from "path";
|
|||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import { versionMigrations } from "@server/db/schema";
|
import { versionMigrations } from "@server/db/schema";
|
||||||
import { desc } from "drizzle-orm";
|
import { desc } from "drizzle-orm";
|
||||||
import { __DIRNAME } from "@server/lib/consts";
|
import { __DIRNAME, APP_PATH } from "@server/lib/consts";
|
||||||
import { loadAppVersion } from "@server/lib/loadAppVersion";
|
import { loadAppVersion } from "@server/lib/loadAppVersion";
|
||||||
import m1 from "./scripts/1.0.0-beta1";
|
import m1 from "./scripts/1.0.0-beta1";
|
||||||
import m2 from "./scripts/1.0.0-beta2";
|
import m2 from "./scripts/1.0.0-beta2";
|
||||||
import m3 from "./scripts/1.0.0-beta3";
|
import m3 from "./scripts/1.0.0-beta3";
|
||||||
|
import m4 from "./scripts/1.0.0-beta5";
|
||||||
|
import m5 from "./scripts/1.0.0-beta6";
|
||||||
|
import { existsSync, mkdirSync } from "fs";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
@@ -17,7 +20,9 @@ import m3 from "./scripts/1.0.0-beta3";
|
|||||||
const migrations = [
|
const migrations = [
|
||||||
{ version: "1.0.0-beta.1", run: m1 },
|
{ version: "1.0.0-beta.1", run: m1 },
|
||||||
{ version: "1.0.0-beta.2", run: m2 },
|
{ version: "1.0.0-beta.2", run: m2 },
|
||||||
{ version: "1.0.0-beta.3", run: m3 }
|
{ version: "1.0.0-beta.3", run: m3 },
|
||||||
|
{ version: "1.0.0-beta.5", run: m4 },
|
||||||
|
{ version: "1.0.0-beta.6", run: m5 }
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
101
server/setup/scripts/1.0.0-beta5.ts
Normal file
101
server/setup/scripts/1.0.0-beta5.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
|
import fs from "fs";
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
import path from "path";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromZodError } from "zod-validation-error";
|
||||||
|
|
||||||
|
export default async function migration() {
|
||||||
|
console.log("Running setup script 1.0.0-beta.5...");
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Validate the structure
|
||||||
|
if (!rawConfig.server) {
|
||||||
|
throw new Error(`Invalid config file: server is missing.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the config
|
||||||
|
rawConfig.server.resource_access_token_param = "p_token";
|
||||||
|
|
||||||
|
// Write the updated YAML back to the file
|
||||||
|
const updatedYaml = yaml.dump(rawConfig);
|
||||||
|
fs.writeFileSync(filePath, updatedYaml, "utf8");
|
||||||
|
|
||||||
|
// then try to update badger in traefik config
|
||||||
|
|
||||||
|
try {
|
||||||
|
const traefikPath = path.join(
|
||||||
|
APP_PATH,
|
||||||
|
"traefik",
|
||||||
|
"traefik_config.yml"
|
||||||
|
);
|
||||||
|
|
||||||
|
// read the traefik file
|
||||||
|
// look for the badger middleware
|
||||||
|
// set the version to v1.0.0-beta.2
|
||||||
|
/*
|
||||||
|
experimental:
|
||||||
|
plugins:
|
||||||
|
badger:
|
||||||
|
moduleName: "github.com/fosrl/badger"
|
||||||
|
version: "v1.0.0-beta.2"
|
||||||
|
*/
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
experimental: z.object({
|
||||||
|
plugins: z.object({
|
||||||
|
badger: z.object({
|
||||||
|
moduleName: z.string(),
|
||||||
|
version: z.string()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const traefikFileContents = fs.readFileSync(traefikPath, "utf8");
|
||||||
|
const traefikConfig = yaml.load(traefikFileContents) as any;
|
||||||
|
|
||||||
|
const parsedConfig = schema.safeParse(traefikConfig);
|
||||||
|
|
||||||
|
if (!parsedConfig.success) {
|
||||||
|
throw new Error(fromZodError(parsedConfig.error).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
traefikConfig.experimental.plugins.badger.version = "v1.0.0-beta.2";
|
||||||
|
|
||||||
|
const updatedTraefikYaml = yaml.dump(traefikConfig);
|
||||||
|
|
||||||
|
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"Updated the version of Badger in your Traefik configuration to v1.0.0-beta.2."
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(
|
||||||
|
"We were unable to update the version of Badger in your Traefik configuration. Please update it manually."
|
||||||
|
);
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Done.");
|
||||||
|
}
|
||||||
52
server/setup/scripts/1.0.0-beta6.ts
Normal file
52
server/setup/scripts/1.0.0-beta6.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
|
import fs from "fs";
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
|
||||||
|
export default async function migration() {
|
||||||
|
console.log("Running setup script 1.0.0-beta.6...");
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Validate the structure
|
||||||
|
if (!rawConfig.server) {
|
||||||
|
throw new Error(`Invalid config file: server is missing.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the config
|
||||||
|
rawConfig.server.cors = {
|
||||||
|
origins: [rawConfig.app.dashboard_url],
|
||||||
|
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||||
|
headers: ["X-CSRF-Token", "Content-Type"],
|
||||||
|
credentials: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write the updated YAML back to the file
|
||||||
|
const updatedYaml = yaml.dump(rawConfig);
|
||||||
|
fs.writeFileSync(filePath, updatedYaml, "utf8");
|
||||||
|
} catch (error) {
|
||||||
|
console.log("We were unable to add CORS to your config file. Please add it manually.")
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Done.");
|
||||||
|
}
|
||||||
@@ -69,6 +69,8 @@ export async function setupServerAdmin() {
|
|||||||
|
|
||||||
const userId = generateId(15);
|
const userId = generateId(15);
|
||||||
|
|
||||||
|
await trx.update(users).set({ serverAdmin: false });
|
||||||
|
|
||||||
await db.insert(users).values({
|
await db.insert(users).values({
|
||||||
userId: userId,
|
userId: userId,
|
||||||
email: email,
|
email: email,
|
||||||
|
|||||||
@@ -57,14 +57,22 @@ import {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList
|
CommandList
|
||||||
} from "@app/components/ui/command";
|
} from "@app/components/ui/command";
|
||||||
import { CheckIcon } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||||
import { register } from "module";
|
import { register } from "module";
|
||||||
import { Label } from "@app/components/ui/label";
|
import { Label } from "@app/components/ui/label";
|
||||||
import { Checkbox } from "@app/components/ui/checkbox";
|
import { Checkbox } from "@app/components/ui/checkbox";
|
||||||
import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
|
import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
|
||||||
import { constructShareLink } from "@app/lib/shareLinks";
|
import {
|
||||||
|
constructDirectShareLink,
|
||||||
|
constructShareLink
|
||||||
|
} from "@app/lib/shareLinks";
|
||||||
import { ShareLinkRow } from "./ShareLinksTable";
|
import { ShareLinkRow } from "./ShareLinksTable";
|
||||||
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
|
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger
|
||||||
|
} from "@app/components/ui/collapsible";
|
||||||
|
|
||||||
type FormProps = {
|
type FormProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -75,6 +83,7 @@ type FormProps = {
|
|||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
resourceId: z.number({ message: "Please select a resource" }),
|
resourceId: z.number({ message: "Please select a resource" }),
|
||||||
resourceName: z.string(),
|
resourceName: z.string(),
|
||||||
|
resourceUrl: z.string(),
|
||||||
timeUnit: z.string(),
|
timeUnit: z.string(),
|
||||||
timeValue: z.coerce.number().int().positive().min(1),
|
timeValue: z.coerce.number().int().positive().min(1),
|
||||||
title: z.string().optional()
|
title: z.string().optional()
|
||||||
@@ -88,14 +97,18 @@ export default function CreateShareLinkForm({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
|
||||||
const [link, setLink] = useState<string | null>(null);
|
const [link, setLink] = useState<string | null>(null);
|
||||||
|
const [directLink, setDirectLink] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [neverExpire, setNeverExpire] = useState(false);
|
const [neverExpire, setNeverExpire] = useState(false);
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const [resources, setResources] = useState<
|
const [resources, setResources] = useState<
|
||||||
{ resourceId: number; name: string }[]
|
{ resourceId: number; name: string; resourceUrl: string }[]
|
||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
const timeUnits = [
|
const timeUnits = [
|
||||||
@@ -139,7 +152,13 @@ export default function CreateShareLinkForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res?.status === 200) {
|
if (res?.status === 200) {
|
||||||
setResources(res.data.data.resources);
|
setResources(
|
||||||
|
res.data.data.resources.map((r) => ({
|
||||||
|
resourceId: r.resourceId,
|
||||||
|
name: r.name,
|
||||||
|
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +221,13 @@ export default function CreateShareLinkForm({
|
|||||||
token.accessToken
|
token.accessToken
|
||||||
);
|
);
|
||||||
setLink(link);
|
setLink(link);
|
||||||
|
const directLink = constructDirectShareLink(
|
||||||
|
env.server.resourceAccessTokenParam,
|
||||||
|
values.resourceUrl,
|
||||||
|
token.accessTokenId,
|
||||||
|
token.accessToken
|
||||||
|
);
|
||||||
|
setDirectLink(directLink);
|
||||||
onCreated?.({
|
onCreated?.({
|
||||||
accessTokenId: token.accessTokenId,
|
accessTokenId: token.accessTokenId,
|
||||||
resourceId: token.resourceId,
|
resourceId: token.resourceId,
|
||||||
@@ -306,6 +332,10 @@ export default function CreateShareLinkForm({
|
|||||||
"resourceName",
|
"resourceName",
|
||||||
r.name
|
r.name
|
||||||
);
|
);
|
||||||
|
form.setValue(
|
||||||
|
"resourceUrl",
|
||||||
|
r.resourceUrl
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CheckIcon
|
<CheckIcon
|
||||||
@@ -462,12 +492,62 @@ export default function CreateShareLinkForm({
|
|||||||
<QRCodeCanvas value={link} size={200} />
|
<QRCodeCanvas value={link} size={200} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto">
|
<Collapsible
|
||||||
<CopyTextBox
|
open={isOpen}
|
||||||
text={link}
|
onOpenChange={setIsOpen}
|
||||||
wrapText={false}
|
className="space-y-2"
|
||||||
/>
|
>
|
||||||
</div>
|
<div className="mx-auto">
|
||||||
|
<CopyTextBox
|
||||||
|
text={link}
|
||||||
|
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">
|
||||||
|
See alternative share
|
||||||
|
links
|
||||||
|
</h4>
|
||||||
|
<div>
|
||||||
|
<ChevronsUpDown className="h-4 w-4" />
|
||||||
|
<span className="sr-only">
|
||||||
|
Toggle
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</div>
|
||||||
|
<CollapsibleContent className="space-y-2">
|
||||||
|
{directLink && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="mx-auto">
|
||||||
|
<CopyTextBox
|
||||||
|
text={directLink}
|
||||||
|
wrapText={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This link does not
|
||||||
|
require visiting in a
|
||||||
|
browser to complete the
|
||||||
|
redirect. It contains
|
||||||
|
the access token
|
||||||
|
directly in the URL,
|
||||||
|
which can be useful for
|
||||||
|
sharing with clients
|
||||||
|
that do not support
|
||||||
|
redirects.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { useRouter } from "next/navigation";
|
|||||||
// import CreateResourceForm from "./CreateResourceForm";
|
// import CreateResourceForm from "./CreateResourceForm";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import { formatAxiosError } from "@app/lib/api";;
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { useToast } from "@app/hooks/useToast";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
@@ -109,15 +109,14 @@ export default function ShareLinksTable({
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem
|
||||||
<button
|
onClick={() => {
|
||||||
onClick={() =>
|
deleteSharelink(
|
||||||
deleteSharelink(
|
resourceRow.accessTokenId
|
||||||
resourceRow.accessTokenId
|
);
|
||||||
)
|
}}
|
||||||
}
|
>
|
||||||
className="text-red-500"
|
<button className="text-red-500">
|
||||||
>
|
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const createSiteFormSchema = z.object({
|
|||||||
.max(30, {
|
.max(30, {
|
||||||
message: "Name must not be longer than 30 characters."
|
message: "Name must not be longer than 30 characters."
|
||||||
}),
|
}),
|
||||||
method: z.enum(["wireguard", "newt"])
|
method: z.enum(["wireguard", "newt", "local"])
|
||||||
});
|
});
|
||||||
|
|
||||||
type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>;
|
type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>;
|
||||||
@@ -79,17 +79,16 @@ export default function CreateSiteForm({
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isChecked, setIsChecked] = useState(false);
|
const [isChecked, setIsChecked] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [keypair, setKeypair] = useState<{
|
const [keypair, setKeypair] = useState<{
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const [siteDefaults, setSiteDefaults] =
|
const [siteDefaults, setSiteDefaults] =
|
||||||
useState<PickSiteDefaultsResponse | null>(null);
|
useState<PickSiteDefaultsResponse | null>(null);
|
||||||
|
|
||||||
const handleCheckboxChange = (checked: boolean) => {
|
const handleCheckboxChange = (checked: boolean) => {
|
||||||
setChecked?.(checked);
|
// setChecked?.(checked);
|
||||||
setIsChecked(checked);
|
setIsChecked(checked);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,6 +97,17 @@ export default function CreateSiteForm({
|
|||||||
defaultValues
|
defaultValues
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const nameField = form.watch("name");
|
||||||
|
const methodField = form.watch("method");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nameIsValid = nameField?.length >= 2 && nameField?.length <= 30;
|
||||||
|
const isFormValid = methodField === "local" || isChecked;
|
||||||
|
|
||||||
|
// Only set checked to true if name is valid AND (method is local OR checkbox is checked)
|
||||||
|
setChecked?.(nameIsValid && isFormValid);
|
||||||
|
}, [nameField, methodField, isChecked, setChecked]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
@@ -114,11 +124,8 @@ export default function CreateSiteForm({
|
|||||||
|
|
||||||
api.get(`/org/${orgId}/pick-site-defaults`)
|
api.get(`/org/${orgId}/pick-site-defaults`)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
// update the default value of the form to be local method
|
||||||
variant: "destructive",
|
form.setValue("method", "local");
|
||||||
title: "Error picking site defaults",
|
|
||||||
description: formatAxiosError(e)
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res && res.status === 200) {
|
if (res && res.status === 200) {
|
||||||
@@ -130,24 +137,56 @@ export default function CreateSiteForm({
|
|||||||
async function onSubmit(data: CreateSiteFormValues) {
|
async function onSubmit(data: CreateSiteFormValues) {
|
||||||
setLoading?.(true);
|
setLoading?.(true);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
if (!siteDefaults || !keypair) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let payload: CreateSiteBody = {
|
let payload: CreateSiteBody = {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
subnet: siteDefaults.subnet,
|
|
||||||
exitNodeId: siteDefaults.exitNodeId,
|
|
||||||
pubKey: keypair.publicKey,
|
|
||||||
type: data.method
|
type: data.method
|
||||||
};
|
};
|
||||||
if (data.method === "newt") {
|
|
||||||
payload.secret = siteDefaults.newtSecret;
|
if (data.method == "wireguard") {
|
||||||
payload.newtId = siteDefaults.newtId;
|
if (!keypair || !siteDefaults) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error creating site",
|
||||||
|
description: "Key pair or site defaults not found"
|
||||||
|
});
|
||||||
|
setLoading?.(false);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
...payload,
|
||||||
|
subnet: siteDefaults.subnet,
|
||||||
|
exitNodeId: siteDefaults.exitNodeId,
|
||||||
|
pubKey: keypair.publicKey
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
if (data.method === "newt") {
|
||||||
|
if (!siteDefaults) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error creating site",
|
||||||
|
description: "Site defaults not found"
|
||||||
|
});
|
||||||
|
setLoading?.(false);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
...payload,
|
||||||
|
subnet: siteDefaults.subnet,
|
||||||
|
exitNodeId: siteDefaults.exitNodeId,
|
||||||
|
secret: siteDefaults.newtSecret,
|
||||||
|
newtId: siteDefaults.newtId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const res = await api
|
const res = await api
|
||||||
.put<
|
.put<AxiosResponse<CreateSiteResponse>>(
|
||||||
AxiosResponse<CreateSiteResponse>
|
`/org/${orgId}/site/`,
|
||||||
>(`/org/${orgId}/site/`, payload)
|
payload
|
||||||
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
@@ -157,18 +196,20 @@ export default function CreateSiteForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res && res.status === 201) {
|
if (res && res.status === 201) {
|
||||||
const niceId = res.data.data.niceId;
|
|
||||||
// navigate to the site page
|
|
||||||
// router.push(`/${orgId}/settings/sites/${niceId}`);
|
|
||||||
|
|
||||||
const data = res.data.data;
|
const data = res.data.data;
|
||||||
|
|
||||||
onCreate?.({
|
onCreate?.({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
id: data.siteId,
|
id: data.siteId,
|
||||||
nice: data.niceId.toString(),
|
nice: data.niceId.toString(),
|
||||||
mbIn: "0 MB",
|
mbIn:
|
||||||
mbOut: "0 MB",
|
data.type == "wireguard" || data.type == "newt"
|
||||||
|
? "0 MB"
|
||||||
|
: "--",
|
||||||
|
mbOut:
|
||||||
|
data.type == "wireguard" || data.type == "newt"
|
||||||
|
? "0 MB"
|
||||||
|
: "--",
|
||||||
orgId: orgId as string,
|
orgId: orgId as string,
|
||||||
type: data.type as any,
|
type: data.type as any,
|
||||||
online: false
|
online: false
|
||||||
@@ -194,10 +235,10 @@ PersistentKeepalive = 5`
|
|||||||
: "";
|
: "";
|
||||||
|
|
||||||
// am I at http or https?
|
// am I at http or https?
|
||||||
let proto = "http:";
|
let proto = "https:";
|
||||||
if (typeof window !== "undefined") {
|
// if (typeof window !== "undefined") {
|
||||||
proto = window.location.protocol;
|
// proto = window.location.protocol;
|
||||||
}
|
// }
|
||||||
|
|
||||||
const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${proto}//${siteDefaults?.endpoint}`;
|
const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${proto}//${siteDefaults?.endpoint}`;
|
||||||
|
|
||||||
@@ -245,12 +286,21 @@ PersistentKeepalive = 5`
|
|||||||
<SelectValue placeholder="Select method" />
|
<SelectValue placeholder="Select method" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="wireguard">
|
<SelectItem value="local">
|
||||||
WireGuard
|
Local
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="newt">
|
<SelectItem
|
||||||
|
value="newt"
|
||||||
|
disabled={!siteDefaults}
|
||||||
|
>
|
||||||
Newt
|
Newt
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
value="wireguard"
|
||||||
|
disabled={!siteDefaults}
|
||||||
|
>
|
||||||
|
WireGuard
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -264,50 +314,76 @@ PersistentKeepalive = 5`
|
|||||||
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{form.watch("method") === "wireguard" && !isLoading ? (
|
{form.watch("method") === "wireguard" && !isLoading ? (
|
||||||
<CopyTextBox text={wgConfig} />
|
<>
|
||||||
|
<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" &&
|
) : form.watch("method") === "wireguard" &&
|
||||||
isLoading ? (
|
isLoading ? (
|
||||||
<p>Loading WireGuard configuration...</p>
|
<p>Loading WireGuard configuration...</p>
|
||||||
) : (
|
) : form.watch("method") === "newt" ? (
|
||||||
<CopyTextBox text={newtConfig} wrapText={false} />
|
<>
|
||||||
)}
|
<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>
|
</div>
|
||||||
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
You will only be able to see the configuration once.
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{form.watch("method") === "newt" && (
|
{form.watch("method") === "newt" && (
|
||||||
<>
|
<Link
|
||||||
<br />
|
className="text-sm text-primary flex items-center gap-1"
|
||||||
<Link
|
href="https://docs.fossorial.io/Newt/install"
|
||||||
className="text-sm text-primary flex items-center gap-1"
|
target="_blank"
|
||||||
href="https://docs.fossorial.io/Newt/install"
|
rel="noopener noreferrer"
|
||||||
target="_blank"
|
>
|
||||||
rel="noopener noreferrer"
|
<span>
|
||||||
>
|
{" "}
|
||||||
<span>
|
Learn how to install Newt on your system
|
||||||
{" "}
|
</span>
|
||||||
Learn how to install Newt on your system
|
<SquareArrowOutUpRight size={14} />
|
||||||
</span>
|
</Link>
|
||||||
<SquareArrowOutUpRight size={14} />
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
{form.watch("method") === "local" && (
|
||||||
<Checkbox
|
<Link
|
||||||
id="terms"
|
className="text-sm text-primary flex items-center gap-1"
|
||||||
checked={isChecked}
|
href="https://docs.fossorial.io/Pangolin/without-tunneling"
|
||||||
onCheckedChange={handleCheckboxChange}
|
target="_blank"
|
||||||
/>
|
rel="noopener noreferrer"
|
||||||
<label
|
|
||||||
htmlFor="terms"
|
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
||||||
>
|
>
|
||||||
I have copied the config
|
<span>
|
||||||
</label>
|
{" "}
|
||||||
</div>
|
Local sites do not tunnel, learn more
|
||||||
|
</span>
|
||||||
|
<SquareArrowOutUpRight size={14} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(form.watch("method") === "newt" ||
|
||||||
|
form.watch("method") === "wireguard") && (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="terms"
|
||||||
|
checked={isChecked}
|
||||||
|
onCheckedChange={handleCheckboxChange}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="terms"
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
I have copied the config
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { useState } from "react";
|
|||||||
import CreateSiteForm from "./CreateSiteForm";
|
import CreateSiteForm from "./CreateSiteForm";
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { useToast } from "@app/hooks/useToast";
|
||||||
import { formatAxiosError } from "@app/lib/api";;
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import CreateSiteFormModal from "./CreateSiteModal";
|
import CreateSiteFormModal from "./CreateSiteModal";
|
||||||
@@ -146,21 +146,27 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const originalRow = row.original;
|
const originalRow = row.original;
|
||||||
|
if (
|
||||||
if (originalRow.online) {
|
originalRow.type == "newt" ||
|
||||||
return (
|
originalRow.type == "wireguard"
|
||||||
<span className="text-green-500 flex items-center space-x-2">
|
) {
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
if (originalRow.online) {
|
||||||
<span>Online</span>
|
return (
|
||||||
</span>
|
<span className="text-green-500 flex items-center space-x-2">
|
||||||
);
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
|
<span>Online</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<span className="text-neutral-500 flex items-center space-x-2">
|
||||||
|
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||||
|
<span>Offline</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return (
|
return <span>--</span>;
|
||||||
<span className="text-neutral-500 flex items-center space-x-2">
|
|
||||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
|
||||||
<span>Offline</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -245,6 +251,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (originalRow.type === "local") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span>Local</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,37 +16,50 @@ type SiteInfoCardProps = {};
|
|||||||
export default function SiteInfoCard({}: SiteInfoCardProps) {
|
export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||||
const { site, updateSite } = useSiteContext();
|
const { site, updateSite } = useSiteContext();
|
||||||
|
|
||||||
|
const getConnectionTypeString = (type: string) => {
|
||||||
|
if (type === "newt") {
|
||||||
|
return "Newt";
|
||||||
|
} else if (type === "wireguard") {
|
||||||
|
return "WireGuard";
|
||||||
|
} else if (type === "local") {
|
||||||
|
return "Local";
|
||||||
|
} else {
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert>
|
<Alert>
|
||||||
<InfoIcon className="h-4 w-4" />
|
<InfoIcon className="h-4 w-4" />
|
||||||
<AlertTitle className="font-semibold">Site Information</AlertTitle>
|
<AlertTitle className="font-semibold">Site Information</AlertTitle>
|
||||||
<AlertDescription className="mt-4">
|
<AlertDescription className="mt-4">
|
||||||
<InfoSections>
|
<InfoSections>
|
||||||
<InfoSection>
|
{(site.type == "newt" || site.type == "wireguard") && (
|
||||||
<InfoSectionTitle>Status</InfoSectionTitle>
|
<>
|
||||||
<InfoSectionContent>
|
<InfoSection>
|
||||||
{site.online ? (
|
<InfoSectionTitle>Status</InfoSectionTitle>
|
||||||
<div className="text-green-500 flex items-center space-x-2">
|
<InfoSectionContent>
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
{site.online ? (
|
||||||
<span>Online</span>
|
<div className="text-green-500 flex items-center space-x-2">
|
||||||
</div>
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
) : (
|
<span>Online</span>
|
||||||
<div className="text-neutral-500 flex items-center space-x-2">
|
</div>
|
||||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
) : (
|
||||||
<span>Offline</span>
|
<div className="text-neutral-500 flex items-center space-x-2">
|
||||||
</div>
|
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||||
)}
|
<span>Offline</span>
|
||||||
</InfoSectionContent>
|
</div>
|
||||||
</InfoSection>
|
)}
|
||||||
<Separator orientation="vertical" />
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>Connection Type</InfoSectionTitle>
|
<InfoSectionTitle>Connection Type</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{site.type === "newt"
|
{getConnectionTypeString(site.type)}
|
||||||
? "Newt"
|
|
||||||
: site.type === "wireguard"
|
|
||||||
? "WireGuard"
|
|
||||||
: "Unknown"}
|
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
</InfoSections>
|
</InfoSections>
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ export default async function SitesPage(props: SitesPageProps) {
|
|||||||
sites = res.data.data.sites;
|
sites = res.data.data.sites;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
function formatSize(mb: number): string {
|
function formatSize(mb: number, type: string): string {
|
||||||
|
if (type === "local") {
|
||||||
|
return "--"; // because we are not able to track the data use in a local site right now
|
||||||
|
}
|
||||||
if (mb >= 1024 * 1024) {
|
if (mb >= 1024 * 1024) {
|
||||||
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
|
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
|
||||||
} else if (mb >= 1024) {
|
} else if (mb >= 1024) {
|
||||||
@@ -38,8 +41,8 @@ export default async function SitesPage(props: SitesPageProps) {
|
|||||||
name: site.name,
|
name: site.name,
|
||||||
id: site.siteId,
|
id: site.siteId,
|
||||||
nice: site.niceId.toString(),
|
nice: site.niceId.toString(),
|
||||||
mbIn: formatSize(site.megabytesIn || 0),
|
mbIn: formatSize(site.megabytesIn || 0, site.type),
|
||||||
mbOut: formatSize(site.megabytesOut || 0),
|
mbOut: formatSize(site.megabytesOut || 0, site.type),
|
||||||
orgId: params.orgId,
|
orgId: params.orgId,
|
||||||
type: site.type as any,
|
type: site.type as any,
|
||||||
online: site.online
|
online: site.online
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default function AccessToken({
|
|||||||
redirectUrl
|
redirectUrl
|
||||||
}: AccessTokenProps) {
|
}: AccessTokenProps) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isValid, setIsValid] = useState(false);
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ export default function AccessToken({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res.data.data.session) {
|
if (res.data.data.session) {
|
||||||
|
setIsValid(true);
|
||||||
window.location.href = redirectUrl;
|
window.location.href = redirectUrl;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -61,24 +63,47 @@ export default function AccessToken({
|
|||||||
check();
|
check();
|
||||||
}, [accessTokenId, accessToken]);
|
}, [accessTokenId, accessToken]);
|
||||||
|
|
||||||
|
function renderTitle() {
|
||||||
|
if (isValid) {
|
||||||
|
return "Access Granted";
|
||||||
|
} else {
|
||||||
|
return "Access URL Invalid";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContent() {
|
||||||
|
if (isValid) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
You have been granted access to this resource. Redirecting
|
||||||
|
you...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
This shared access URL is invalid. Please contact the
|
||||||
|
resource owner for a new URL.
|
||||||
|
<div className="text-center mt-4">
|
||||||
|
<Button>
|
||||||
|
<Link href="/">Go Home</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return loading ? (
|
return loading ? (
|
||||||
<div></div>
|
<div></div>
|
||||||
) : (
|
) : (
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-center text-2xl font-bold">
|
<CardTitle className="text-center text-2xl font-bold">
|
||||||
Access URL Invalid
|
{renderTitle()}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>{renderContent()}</CardContent>
|
||||||
This shared access URL is invalid. Please contact the resource
|
|
||||||
owner for a new URL.
|
|
||||||
<div className="text-center mt-4">
|
|
||||||
<Button>
|
|
||||||
<Link href="/">Go Home</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,11 +45,10 @@ export default async function ResourceAuthPage(props: {
|
|||||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
const user = await getUser({ skipCheckVerifyEmail: true });
|
||||||
|
|
||||||
if (!authInfo) {
|
if (!authInfo) {
|
||||||
{
|
// TODO: fix this
|
||||||
/* @ts-ignore */
|
|
||||||
} // TODO: fix this
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
|
{/* @ts-ignore */}
|
||||||
<ResourceNotFound />
|
<ResourceNotFound />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const buttonVariants = cva(
|
|||||||
secondary:
|
secondary:
|
||||||
"bg-secondary border border-input text-secondary-foreground hover:bg-secondary/80",
|
"bg-secondary border border-input text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
text: "",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
11
src/components/ui/collapsible.tsx
Normal file
11
src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||||
|
|
||||||
|
const Collapsible = CollapsiblePrimitive.Root
|
||||||
|
|
||||||
|
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
|
||||||
|
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
@@ -6,7 +6,8 @@ export function pullEnv(): Env {
|
|||||||
nextPort: process.env.NEXT_PORT as string,
|
nextPort: process.env.NEXT_PORT as string,
|
||||||
externalPort: process.env.SERVER_EXTERNAL_PORT as string,
|
externalPort: process.env.SERVER_EXTERNAL_PORT as string,
|
||||||
sessionCookieName: process.env.SESSION_COOKIE_NAME as string,
|
sessionCookieName: process.env.SESSION_COOKIE_NAME as string,
|
||||||
resourceSessionCookieName: process.env.RESOURCE_SESSION_COOKIE_NAME as string
|
resourceSessionCookieName: process.env.RESOURCE_SESSION_COOKIE_NAME as string,
|
||||||
|
resourceAccessTokenParam: process.env.RESOURCE_ACCESS_TOKEN_PARAM as string
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
environment: process.env.ENVIRONMENT as string,
|
environment: process.env.ENVIRONMENT as string,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { pullEnv } from "./pullEnv";
|
||||||
|
|
||||||
export function constructShareLink(
|
export function constructShareLink(
|
||||||
resourceId: number,
|
resourceId: number,
|
||||||
id: string,
|
id: string,
|
||||||
@@ -5,3 +7,12 @@ export function constructShareLink(
|
|||||||
) {
|
) {
|
||||||
return `${window.location.origin}/auth/resource/${resourceId}?token=${id}.${token}`;
|
return `${window.location.origin}/auth/resource/${resourceId}?token=${id}.${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function constructDirectShareLink(
|
||||||
|
param: string,
|
||||||
|
resourceUrl: string,
|
||||||
|
id: string,
|
||||||
|
token: string
|
||||||
|
) {
|
||||||
|
return `${resourceUrl}?${param}=${id}.${token}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type Env = {
|
|||||||
nextPort: string;
|
nextPort: string;
|
||||||
sessionCookieName: string;
|
sessionCookieName: string;
|
||||||
resourceSessionCookieName: string;
|
resourceSessionCookieName: string;
|
||||||
|
resourceAccessTokenParam: string;
|
||||||
},
|
},
|
||||||
email: {
|
email: {
|
||||||
emailEnabled: boolean;
|
emailEnabled: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user