mirror of
https://github.com/fosrl/pangolin.git
synced 2025-12-03 14:44:40 +00:00
Compare commits
38 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 | ||
|
|
025c2c5306 | ||
|
|
fa39b708a9 | ||
|
|
f5fda5d8ea |
@@ -27,6 +27,8 @@ COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/init ./dist/init
|
||||
|
||||
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 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)
|
||||
- [Full Documentation](https://docs.fossorial.io)
|
||||
|
||||
### Authors and Maintainers
|
||||
|
||||
- [Milo Schwartz](https://github.com/miloschwartz)
|
||||
- [Owen Schwartz](https://github.com/oschwartz10612)
|
||||
|
||||
## Preview
|
||||
|
||||
<img src="public/screenshots/sites.png" alt="Preview"/>
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
app:
|
||||
dashboard_url: http://localhost
|
||||
dashboard_url: http://localhost:3002
|
||||
base_domain: localhost
|
||||
log_level: debug
|
||||
log_level: info
|
||||
save_logs: false
|
||||
|
||||
server:
|
||||
external_port: 3000
|
||||
internal_port: 3001
|
||||
next_port: 3002
|
||||
internal_hostname: localhost
|
||||
secure_cookies: false
|
||||
internal_hostname: pangolin
|
||||
secure_cookies: true
|
||||
session_cookie_name: p_session
|
||||
resource_session_cookie_name: p_resource_session
|
||||
resource_access_token_param: p_token
|
||||
|
||||
traefik:
|
||||
cert_resolver: letsencrypt
|
||||
@@ -38,3 +39,5 @@ users:
|
||||
|
||||
flags:
|
||||
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
|
||||
next_port: 3002
|
||||
internal_hostname: pangolin
|
||||
secure_cookies: false
|
||||
secure_cookies: true
|
||||
session_cookie_name: p_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:
|
||||
cert_resolver: letsencrypt
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
pangolin:
|
||||
image: fosrl/pangolin:latest
|
||||
image: fosrl/pangolin:{{.PangolinVersion}}
|
||||
container_name: pangolin
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
@@ -11,8 +11,9 @@ services:
|
||||
timeout: "3s"
|
||||
retries: 5
|
||||
|
||||
{{if .InstallGerbil}}
|
||||
gerbil:
|
||||
image: fosrl/gerbil:latest
|
||||
image: fosrl/gerbil:{{.GerbilVersion}}
|
||||
container_name: gerbil
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@@ -32,12 +33,20 @@ services:
|
||||
- 51820:51820/udp
|
||||
- 443:443 # Port for traefik because of the network_mode
|
||||
- 80:80 # Port for traefik because of the network_mode
|
||||
{{end}}
|
||||
|
||||
traefik:
|
||||
image: traefik:v3.1
|
||||
container_name: traefik
|
||||
restart: unless-stopped
|
||||
{{if .InstallGerbil}}
|
||||
network_mode: service:gerbil # Ports appear on the gerbil service
|
||||
{{end}}
|
||||
{{if not .InstallGerbil}}
|
||||
ports:
|
||||
- 443:443
|
||||
- 80:80
|
||||
{{end}}
|
||||
depends_on:
|
||||
pangolin:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -13,7 +13,7 @@ experimental:
|
||||
plugins:
|
||||
badger:
|
||||
moduleName: "github.com/fosrl/badger"
|
||||
version: "v1.0.0-beta.1"
|
||||
version: "v1.0.0-beta.2"
|
||||
|
||||
log:
|
||||
level: "INFO"
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
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"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/template"
|
||||
"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/*
|
||||
var configFiles embed.FS
|
||||
|
||||
type Config struct {
|
||||
BaseDomain string `yaml:"baseDomain"`
|
||||
DashboardDomain string `yaml:"dashboardUrl"`
|
||||
LetsEncryptEmail string `yaml:"letsEncryptEmail"`
|
||||
AdminUserEmail string `yaml:"adminUserEmail"`
|
||||
AdminUserPassword string `yaml:"adminUserPassword"`
|
||||
DisableSignupWithoutInvite bool `yaml:"disableSignupWithoutInvite"`
|
||||
DisableUserCreateOrg bool `yaml:"disableUserCreateOrg"`
|
||||
EnableEmail bool `yaml:"enableEmail"`
|
||||
EmailSMTPHost string `yaml:"emailSMTPHost"`
|
||||
EmailSMTPPort int `yaml:"emailSMTPPort"`
|
||||
EmailSMTPUser string `yaml:"emailSMTPUser"`
|
||||
EmailSMTPPass string `yaml:"emailSMTPPass"`
|
||||
EmailNoReply string `yaml:"emailNoReply"`
|
||||
PangolinVersion string
|
||||
GerbilVersion string
|
||||
BaseDomain string
|
||||
DashboardDomain string
|
||||
LetsEncryptEmail string
|
||||
AdminUserEmail string
|
||||
AdminUserPassword string
|
||||
DisableSignupWithoutInvite bool
|
||||
DisableUserCreateOrg bool
|
||||
EnableEmail bool
|
||||
EmailSMTPHost string
|
||||
EmailSMTPPort int
|
||||
EmailSMTPUser string
|
||||
EmailSMTPPass string
|
||||
EmailNoReply string
|
||||
InstallGerbil bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -45,13 +56,16 @@ func main() {
|
||||
// check if there is already a config file
|
||||
if _, err := os.Stat("config/config.yml"); err != nil {
|
||||
config := collectUserInput(reader)
|
||||
|
||||
loadVersions(&config)
|
||||
|
||||
if err := createConfigFiles(config); err != nil {
|
||||
fmt.Printf("Error creating config files: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if !isDockerInstalled() && runtime.GOOS == "linux" {
|
||||
if shouldInstallDocker() {
|
||||
if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
|
||||
installDocker()
|
||||
}
|
||||
}
|
||||
@@ -82,6 +96,24 @@ func readString(reader *bufio.Reader, prompt string, defaultValue string) string
|
||||
return input
|
||||
}
|
||||
|
||||
func readPassword(prompt string) string {
|
||||
fmt.Print(prompt + ": ")
|
||||
|
||||
// Read password without echo
|
||||
password, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Println() // Add a newline since ReadPassword doesn't add one
|
||||
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
input := strings.TrimSpace(string(password))
|
||||
if input == "" {
|
||||
return readPassword(prompt)
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool {
|
||||
defaultStr := "no"
|
||||
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.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.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunned connections", true)
|
||||
|
||||
// Admin user configuration
|
||||
fmt.Println("\n=== Admin User Configuration ===")
|
||||
config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain)
|
||||
for {
|
||||
config.AdminUserPassword = readString(reader, "Enter admin user password", "")
|
||||
if valid, message := validatePassword(config.AdminUserPassword); valid {
|
||||
break
|
||||
pass1 := readPassword("Create admin user password")
|
||||
pass2 := readPassword("Confirm admin user password")
|
||||
|
||||
if pass1 != pass2 {
|
||||
fmt.Println("Passwords do not match")
|
||||
} else {
|
||||
fmt.Println("Invalid password:", message)
|
||||
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")
|
||||
config.AdminUserPassword = pass1
|
||||
if valid, message := validatePassword(config.AdminUserPassword); valid {
|
||||
break
|
||||
} else {
|
||||
fmt.Println("Invalid password:", message)
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
// Detect Linux distribution
|
||||
cmd := exec.Command("cat", "/etc/os-release")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fosrl/pangolin",
|
||||
"version": "1.0.0-beta.3",
|
||||
"version": "1.0.0-beta.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",
|
||||
@@ -26,6 +26,7 @@
|
||||
"@oslojs/encoding": "1.1.0",
|
||||
"@radix-ui/react-avatar": "1.1.2",
|
||||
"@radix-ui/react-checkbox": "1.1.3",
|
||||
"@radix-ui/react-collapsible": "1.1.2",
|
||||
"@radix-ui/react-dialog": "1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.4",
|
||||
"@radix-ui/react-icons": "1.3.2",
|
||||
|
||||
@@ -20,23 +20,32 @@ const externalPort = config.getRawConfig().server.external_port;
|
||||
export function createApiServer() {
|
||||
const apiServer = express();
|
||||
|
||||
// Middleware setup
|
||||
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"]
|
||||
};
|
||||
if (config.getRawConfig().server.trust_proxy) {
|
||||
apiServer.set("trust proxy", 1);
|
||||
}
|
||||
|
||||
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(csrfProtectionMiddleware);
|
||||
}
|
||||
@@ -47,7 +56,8 @@ export function createApiServer() {
|
||||
if (!dev) {
|
||||
apiServer.use(
|
||||
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,
|
||||
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 {
|
||||
encodeBase32LowerCaseNoPadding,
|
||||
encodeHexLowerCase,
|
||||
encodeHexLowerCase
|
||||
} from "@oslojs/encoding";
|
||||
import { sha256 } from "@oslojs/crypto/sha2";
|
||||
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 type { RandomReader } 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 SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
|
||||
export const COOKIE_DOMAIN = "." + config.getBaseDomain();
|
||||
@@ -24,25 +26,25 @@ export function generateSessionToken(): string {
|
||||
|
||||
export async function createSession(
|
||||
token: string,
|
||||
userId: string,
|
||||
userId: string
|
||||
): Promise<Session> {
|
||||
const sessionId = encodeHexLowerCase(
|
||||
sha256(new TextEncoder().encode(token)),
|
||||
sha256(new TextEncoder().encode(token))
|
||||
);
|
||||
const session: Session = {
|
||||
sessionId: sessionId,
|
||||
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);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function validateSessionToken(
|
||||
token: string,
|
||||
token: string
|
||||
): Promise<SessionValidationResult> {
|
||||
const sessionId = encodeHexLowerCase(
|
||||
sha256(new TextEncoder().encode(token)),
|
||||
sha256(new TextEncoder().encode(token))
|
||||
);
|
||||
const result = await db
|
||||
.select({ user: users, session: sessions })
|
||||
@@ -61,12 +63,12 @@ export async function validateSessionToken(
|
||||
}
|
||||
if (Date.now() >= session.expiresAt - SESSION_COOKIE_EXPIRES / 2) {
|
||||
session.expiresAt = new Date(
|
||||
Date.now() + SESSION_COOKIE_EXPIRES,
|
||||
Date.now() + SESSION_COOKIE_EXPIRES
|
||||
).getTime();
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({
|
||||
expiresAt: session.expiresAt,
|
||||
expiresAt: session.expiresAt
|
||||
})
|
||||
.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));
|
||||
}
|
||||
|
||||
export function serializeSessionCookie(token: string): string {
|
||||
if (SECURE_COOKIES) {
|
||||
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
||||
export function serializeSessionCookie(
|
||||
token: string,
|
||||
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 {
|
||||
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 {
|
||||
if (SECURE_COOKIES) {
|
||||
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
||||
export function createBlankSessionTokenCookie(isSecure: boolean): string {
|
||||
if (isSecure) {
|
||||
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 {
|
||||
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 = {
|
||||
read(bytes: Uint8Array): void {
|
||||
crypto.getRandomValues(bytes);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
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 fs from "fs/promises";
|
||||
import { APP_PATH } from "@server/lib/consts";
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
|
||||
export const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||
export const exists = await checkFileExists(location);
|
||||
|
||||
bootstrapVolume();
|
||||
|
||||
const sqlite = new Database(location);
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
|
||||
@@ -21,3 +24,29 @@ async function checkFileExists(filePath: string): Promise<boolean> {
|
||||
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,36 +3,74 @@ import yaml from "js-yaml";
|
||||
import path from "path";
|
||||
import { z } from "zod";
|
||||
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 { passwordSchema } from "@server/auth/passwordSchema";
|
||||
import stoi from "./stoi";
|
||||
|
||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||
const hostnameSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^(?!-)[a-zA-Z0-9-]{1,63}(?<!-)(\.[a-zA-Z]{2,})*$/,
|
||||
"Invalid hostname. Must be a valid hostname like 'localhost' or 'test.example.com'."
|
||||
);
|
||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$/
|
||||
)
|
||||
.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({
|
||||
dashboard_url: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("APP_DASHBOARDURL"))
|
||||
.pipe(z.string().url())
|
||||
.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"]),
|
||||
save_logs: z.boolean()
|
||||
}),
|
||||
server: z.object({
|
||||
external_port: portSchema,
|
||||
internal_port: portSchema,
|
||||
next_port: portSchema,
|
||||
external_port: portSchema
|
||||
.optional()
|
||||
.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()),
|
||||
secure_cookies: z.boolean(),
|
||||
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({
|
||||
http_entrypoint: z.string(),
|
||||
@@ -41,8 +79,17 @@ const environmentSchema = z.object({
|
||||
prefer_wildcard_cert: z.boolean().optional()
|
||||
}),
|
||||
gerbil: z.object({
|
||||
start_port: portSchema,
|
||||
base_endpoint: z.string().transform((url) => url.toLowerCase()),
|
||||
start_port: portSchema
|
||||
.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(),
|
||||
subnet_group: z.string(),
|
||||
block_size: z.number().positive().gt(0),
|
||||
@@ -71,8 +118,16 @@ const environmentSchema = z.object({
|
||||
.optional(),
|
||||
users: 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
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("USERS_SERVERADMIN_PASSWORD"))
|
||||
.pipe(passwordSchema)
|
||||
})
|
||||
}),
|
||||
flags: z
|
||||
@@ -85,12 +140,18 @@ const environmentSchema = z.object({
|
||||
});
|
||||
|
||||
export class Config {
|
||||
private rawConfig!: z.infer<typeof environmentSchema>;
|
||||
private rawConfig!: z.infer<typeof configSchema>;
|
||||
|
||||
constructor() {
|
||||
this.loadConfig();
|
||||
|
||||
if (process.env.GENERATE_TRAEFIK_CONFIG === "true") {
|
||||
this.createTraefikConfig();
|
||||
}
|
||||
}
|
||||
|
||||
public loadEnvironment() {}
|
||||
|
||||
public loadConfig() {
|
||||
const loadConfig = (configPath: string) => {
|
||||
try {
|
||||
@@ -131,6 +192,9 @@ export class Config {
|
||||
);
|
||||
environment = loadConfig(configFilePath1);
|
||||
} 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) {
|
||||
throw new Error(
|
||||
`Error creating configuration file from example: ${
|
||||
@@ -151,7 +215,7 @@ export class Config {
|
||||
throw new Error("No configuration file found");
|
||||
}
|
||||
|
||||
const parsedConfig = environmentSchema.safeParse(environment);
|
||||
const parsedConfig = configSchema.safeParse(environment);
|
||||
|
||||
if (!parsedConfig.success) {
|
||||
const errors = fromError(parsedConfig.error);
|
||||
@@ -186,6 +250,8 @@ export class Config {
|
||||
?.disable_user_create_org
|
||||
? "true"
|
||||
: "false";
|
||||
process.env.RESOURCE_ACCESS_TOKEN_PARAM =
|
||||
parsedConfig.data.server.resource_access_token_param;
|
||||
|
||||
this.rawConfig = parsedConfig.data;
|
||||
}
|
||||
@@ -197,6 +263,72 @@ export class Config {
|
||||
public getBaseDomain(): string {
|
||||
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();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { resourceAccessToken, resources, userOrgs } from "@server/db/schema";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { canUserAccessResource } from "@server/lib/canUserAccessResource";
|
||||
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
|
||||
|
||||
export async function verifyAccessTokenAccess(
|
||||
req: Request,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { resources, targets, userOrgs } from "@server/db/schema";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { canUserAccessResource } from "../lib/canUserAccessResource";
|
||||
import { canUserAccessResource } from "../auth/canUserAccessResource";
|
||||
|
||||
export async function verifyTargetAccess(
|
||||
req: Request,
|
||||
|
||||
@@ -120,7 +120,8 @@ export async function login(
|
||||
|
||||
const token = generateSessionToken();
|
||||
await createSession(token, existingUser.userId);
|
||||
const cookie = serializeSessionCookie(token);
|
||||
const isSecure = req.protocol === "https";
|
||||
const cookie = serializeSessionCookie(token, isSecure);
|
||||
|
||||
res.appendHeader("Set-Cookie", cookie);
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@ export async function logout(
|
||||
|
||||
try {
|
||||
await invalidateSession(sessionId);
|
||||
res.setHeader("Set-Cookie", createBlankSessionTokenCookie());
|
||||
const isSecure = req.protocol === "https";
|
||||
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
|
||||
|
||||
return response<null>(res, {
|
||||
data: null,
|
||||
|
||||
@@ -158,7 +158,8 @@ export async function signup(
|
||||
|
||||
const token = generateSessionToken();
|
||||
await createSession(token, userId);
|
||||
const cookie = serializeSessionCookie(token);
|
||||
const isSecure = req.protocol === "https";
|
||||
const cookie = serializeSessionCookie(token, isSecure);
|
||||
res.appendHeader("Set-Cookie", cookie);
|
||||
|
||||
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 db from "@server/db";
|
||||
import {
|
||||
ResourceAccessToken,
|
||||
resourceAccessToken,
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
@@ -17,9 +18,15 @@ import {
|
||||
} from "@server/db/schema";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
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 logger from "@server/logger";
|
||||
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||
import { generateSessionToken } from "@server/auth";
|
||||
|
||||
const verifyResourceSessionSchema = z.object({
|
||||
sessions: z.record(z.string()).optional(),
|
||||
@@ -28,6 +35,7 @@ const verifyResourceSessionSchema = z.object({
|
||||
host: z.string(),
|
||||
path: z.string(),
|
||||
method: z.string(),
|
||||
accessToken: z.string().optional(),
|
||||
tls: z.boolean()
|
||||
});
|
||||
|
||||
@@ -59,7 +67,8 @@ export async function verifyResourceSession(
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessions, host, originalRequestURL } = parsedBody.data;
|
||||
const { sessions, host, originalRequestURL, accessToken: token } =
|
||||
parsedBody.data;
|
||||
|
||||
const [result] = await db
|
||||
.select()
|
||||
@@ -103,11 +112,41 @@ export async function verifyResourceSession(
|
||||
|
||||
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) {
|
||||
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
|
||||
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");
|
||||
return notAllowed(res, redirectUrl);
|
||||
} catch (e) {
|
||||
@@ -209,11 +258,41 @@ function allowed(res: Response) {
|
||||
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(
|
||||
user: User,
|
||||
resource: Resource
|
||||
): Promise<boolean> {
|
||||
if (config.getRawConfig().flags?.require_email_verification && !user.emailVerified) {
|
||||
if (
|
||||
config.getRawConfig().flags?.require_email_verification &&
|
||||
!user.emailVerified
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,9 @@ export async function getConfig(req: Request, res: Response, next: NextFunction)
|
||||
let exitNode;
|
||||
if (exitNodeQuery.length === 0) {
|
||||
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 = "";
|
||||
if (config.getRawConfig().gerbil.use_subdomain) {
|
||||
subEndpoint = await getUniqueExitNodeEndpointName();
|
||||
|
||||
@@ -14,9 +14,7 @@ import {
|
||||
} from "@server/auth/sessions/resource";
|
||||
import config from "@server/lib/config";
|
||||
import logger from "@server/logger";
|
||||
import { verify } from "@node-rs/argon2";
|
||||
import { isWithinExpirationDate } from "oslo";
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||
|
||||
const authWithAccessTokenBodySchema = z
|
||||
.object({
|
||||
@@ -69,58 +67,38 @@ export async function authWithAccessToken(
|
||||
const { accessToken, accessTokenId } = parsedBody.data;
|
||||
|
||||
try {
|
||||
const [result] = await db
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resourceAccessToken)
|
||||
.where(
|
||||
and(
|
||||
eq(resourceAccessToken.resourceId, resourceId),
|
||||
eq(resourceAccessToken.accessTokenId, accessTokenId)
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resources,
|
||||
eq(resources.resourceId, resourceAccessToken.resourceId)
|
||||
)
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.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) {
|
||||
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) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Invalid access token")
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
tokenItem.expiresAt &&
|
||||
!isWithinExpirationDate(new Date(tokenItem.expiresAt))
|
||||
) {
|
||||
if (!valid) {
|
||||
return next(
|
||||
createHttpError(
|
||||
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(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Resource has no pincode protection"
|
||||
)
|
||||
"Resource has no pincode protection"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const validPincode = verifyPassword(
|
||||
const validPincode = await verifyPassword(
|
||||
pincode,
|
||||
definedPincode.pincodeHash
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ const createSiteParamsSchema = z
|
||||
const createSiteSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
exitNodeId: z.number().int().positive(),
|
||||
exitNodeId: z.number().int().positive().optional(),
|
||||
// subdomain: z
|
||||
// .string()
|
||||
// .min(1)
|
||||
@@ -32,7 +32,7 @@ const createSiteSchema = z
|
||||
// .transform((val) => val.toLowerCase())
|
||||
// .optional(),
|
||||
pubKey: z.string().optional(),
|
||||
subnet: z.string(),
|
||||
subnet: z.string().optional(),
|
||||
newtId: z.string().optional(),
|
||||
secret: z.string().optional(),
|
||||
type: z.string()
|
||||
@@ -82,28 +82,46 @@ export async function createSite(
|
||||
|
||||
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) => {
|
||||
const [newSite] = await trx
|
||||
.insert(sites)
|
||||
.values(payload)
|
||||
.returning();
|
||||
let newSite: Site;
|
||||
|
||||
if (exitNodeId) {
|
||||
// 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
|
||||
.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, {
|
||||
publicKey: pubKey,
|
||||
allowedIps: []
|
||||
|
||||
@@ -123,88 +123,100 @@ export async function createTarget(
|
||||
);
|
||||
}
|
||||
|
||||
// make sure the target is within the site subnet
|
||||
if (
|
||||
site.type == "wireguard" &&
|
||||
!isIpInCidr(targetData.ip, site.subnet!)
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Target IP is not within the site subnet`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch resources for this site
|
||||
const resourcesRes = await db.query.resources.findMany({
|
||||
where: eq(resources.siteId, site.siteId)
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
let internalPort!: number;
|
||||
// pick a port
|
||||
for (let i = 40000; i < 65535; i++) {
|
||||
if (!targetInternalPorts.includes(i)) {
|
||||
internalPort = i;
|
||||
break;
|
||||
let newTarget: Target[] = [];
|
||||
if (site.type == "local") {
|
||||
newTarget = await db
|
||||
.insert(targets)
|
||||
.values({
|
||||
resourceId,
|
||||
protocol: "tcp", // hard code for now
|
||||
...targetData
|
||||
})
|
||||
.returning();
|
||||
} else {
|
||||
// make sure the target is within the site subnet
|
||||
if (
|
||||
site.type == "wireguard" &&
|
||||
!isIpInCidr(targetData.ip, site.subnet!)
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Target IP is not within the site subnet`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!internalPort) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`No available internal port`
|
||||
)
|
||||
// Fetch resources for this site
|
||||
const resourcesRes = await db.query.resources.findMany({
|
||||
where: eq(resources.siteId, site.siteId)
|
||||
});
|
||||
|
||||
// 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
|
||||
.insert(targets)
|
||||
.values({
|
||||
resourceId,
|
||||
protocol: "tcp", // hard code for now
|
||||
internalPort,
|
||||
...targetData
|
||||
})
|
||||
.returning();
|
||||
let internalPort!: number;
|
||||
// pick a port
|
||||
for (let i = 40000; i < 65535; i++) {
|
||||
if (!targetInternalPorts.includes(i)) {
|
||||
internalPort = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// add the new target to the targetIps array
|
||||
targetIps.push(`${targetData.ip}/32`);
|
||||
if (!internalPort) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`No available internal port`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
newTarget = await db
|
||||
.insert(targets)
|
||||
.values({
|
||||
resourceId,
|
||||
protocol: "tcp", // hard code for now
|
||||
internalPort,
|
||||
...targetData
|
||||
})
|
||||
.returning();
|
||||
|
||||
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,
|
||||
userSessionCookieName:
|
||||
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() {
|
||||
const domain = config.getBaseDomain();
|
||||
const endpoint = config.getRawConfig().gerbil.base_endpoint;
|
||||
const listenPort = config.getRawConfig().gerbil.start_port;
|
||||
|
||||
// update the domain on all of the orgs where the domain is not equal to the new domain
|
||||
// TODO: eventually each org could have a unique domain that we do not want to overwrite, so this will be unnecessary
|
||||
@@ -14,6 +15,8 @@ export async function copyInConfig() {
|
||||
|
||||
// TODO: eventually each exit node could have a different endpoint
|
||||
await db.update(exitNodes).set({ endpoint }).where(ne(exitNodes.endpoint, endpoint));
|
||||
// TODO: eventually each exit node could have a different port
|
||||
await db.update(exitNodes).set({ listenPort }).where(ne(exitNodes.listenPort, listenPort));
|
||||
|
||||
// update all resources fullDomain to use the new domain
|
||||
await db.transaction(async (trx) => {
|
||||
|
||||
@@ -4,11 +4,14 @@ import path from "path";
|
||||
import semver from "semver";
|
||||
import { versionMigrations } from "@server/db/schema";
|
||||
import { desc } from "drizzle-orm";
|
||||
import { __DIRNAME } from "@server/lib/consts";
|
||||
import { __DIRNAME, APP_PATH } from "@server/lib/consts";
|
||||
import { loadAppVersion } from "@server/lib/loadAppVersion";
|
||||
import m1 from "./scripts/1.0.0-beta1";
|
||||
import m2 from "./scripts/1.0.0-beta2";
|
||||
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
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
@@ -17,7 +20,9 @@ import m3 from "./scripts/1.0.0-beta3";
|
||||
const migrations = [
|
||||
{ version: "1.0.0-beta.1", run: m1 },
|
||||
{ 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
|
||||
] 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);
|
||||
|
||||
await trx.update(users).set({ serverAdmin: false });
|
||||
|
||||
await db.insert(users).values({
|
||||
userId: userId,
|
||||
email: email,
|
||||
|
||||
@@ -57,14 +57,22 @@ import {
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import { register } from "module";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
|
||||
import { constructShareLink } from "@app/lib/shareLinks";
|
||||
import {
|
||||
constructDirectShareLink,
|
||||
constructShareLink
|
||||
} from "@app/lib/shareLinks";
|
||||
import { ShareLinkRow } from "./ShareLinksTable";
|
||||
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger
|
||||
} from "@app/components/ui/collapsible";
|
||||
|
||||
type FormProps = {
|
||||
open: boolean;
|
||||
@@ -75,6 +83,7 @@ type FormProps = {
|
||||
const formSchema = z.object({
|
||||
resourceId: z.number({ message: "Please select a resource" }),
|
||||
resourceName: z.string(),
|
||||
resourceUrl: z.string(),
|
||||
timeUnit: z.string(),
|
||||
timeValue: z.coerce.number().int().positive().min(1),
|
||||
title: z.string().optional()
|
||||
@@ -88,14 +97,18 @@ export default function CreateShareLinkForm({
|
||||
const { toast } = useToast();
|
||||
const { org } = useOrgContext();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const [link, setLink] = useState<string | null>(null);
|
||||
const [directLink, setDirectLink] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [neverExpire, setNeverExpire] = useState(false);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const [resources, setResources] = useState<
|
||||
{ resourceId: number; name: string }[]
|
||||
{ resourceId: number; name: string; resourceUrl: string }[]
|
||||
>([]);
|
||||
|
||||
const timeUnits = [
|
||||
@@ -139,7 +152,13 @@ export default function CreateShareLinkForm({
|
||||
});
|
||||
|
||||
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
|
||||
);
|
||||
setLink(link);
|
||||
const directLink = constructDirectShareLink(
|
||||
env.server.resourceAccessTokenParam,
|
||||
values.resourceUrl,
|
||||
token.accessTokenId,
|
||||
token.accessToken
|
||||
);
|
||||
setDirectLink(directLink);
|
||||
onCreated?.({
|
||||
accessTokenId: token.accessTokenId,
|
||||
resourceId: token.resourceId,
|
||||
@@ -306,6 +332,10 @@ export default function CreateShareLinkForm({
|
||||
"resourceName",
|
||||
r.name
|
||||
);
|
||||
form.setValue(
|
||||
"resourceUrl",
|
||||
r.resourceUrl
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
@@ -462,12 +492,62 @@ export default function CreateShareLinkForm({
|
||||
<QRCodeCanvas value={link} size={200} />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto">
|
||||
<CopyTextBox
|
||||
text={link}
|
||||
wrapText={false}
|
||||
/>
|
||||
</div>
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
className="space-y-2"
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useRouter } from "next/navigation";
|
||||
// import CreateResourceForm from "./CreateResourceForm";
|
||||
import { useState } from "react";
|
||||
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 { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
@@ -109,15 +109,14 @@ export default function ShareLinksTable({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
onClick={() =>
|
||||
deleteSharelink(
|
||||
resourceRow.accessTokenId
|
||||
)
|
||||
}
|
||||
className="text-red-500"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
deleteSharelink(
|
||||
resourceRow.accessTokenId
|
||||
);
|
||||
}}
|
||||
>
|
||||
<button className="text-red-500">
|
||||
Delete
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -49,7 +49,7 @@ const createSiteFormSchema = z.object({
|
||||
.max(30, {
|
||||
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>;
|
||||
@@ -79,17 +79,16 @@ export default function CreateSiteForm({
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isChecked, setIsChecked] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [keypair, setKeypair] = useState<{
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
} | null>(null);
|
||||
|
||||
const [siteDefaults, setSiteDefaults] =
|
||||
useState<PickSiteDefaultsResponse | null>(null);
|
||||
|
||||
const handleCheckboxChange = (checked: boolean) => {
|
||||
setChecked?.(checked);
|
||||
// setChecked?.(checked);
|
||||
setIsChecked(checked);
|
||||
};
|
||||
|
||||
@@ -98,6 +97,17 @@ export default function CreateSiteForm({
|
||||
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(() => {
|
||||
if (!open) return;
|
||||
|
||||
@@ -114,11 +124,8 @@ export default function CreateSiteForm({
|
||||
|
||||
api.get(`/org/${orgId}/pick-site-defaults`)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error picking site defaults",
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
// update the default value of the form to be local method
|
||||
form.setValue("method", "local");
|
||||
})
|
||||
.then((res) => {
|
||||
if (res && res.status === 200) {
|
||||
@@ -130,24 +137,56 @@ export default function CreateSiteForm({
|
||||
async function onSubmit(data: CreateSiteFormValues) {
|
||||
setLoading?.(true);
|
||||
setIsLoading(true);
|
||||
if (!siteDefaults || !keypair) {
|
||||
return;
|
||||
}
|
||||
let payload: CreateSiteBody = {
|
||||
name: data.name,
|
||||
subnet: siteDefaults.subnet,
|
||||
exitNodeId: siteDefaults.exitNodeId,
|
||||
pubKey: keypair.publicKey,
|
||||
type: data.method
|
||||
};
|
||||
if (data.method === "newt") {
|
||||
payload.secret = siteDefaults.newtSecret;
|
||||
payload.newtId = siteDefaults.newtId;
|
||||
|
||||
if (data.method == "wireguard") {
|
||||
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
|
||||
.put<
|
||||
AxiosResponse<CreateSiteResponse>
|
||||
>(`/org/${orgId}/site/`, payload)
|
||||
.put<AxiosResponse<CreateSiteResponse>>(
|
||||
`/org/${orgId}/site/`,
|
||||
payload
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
@@ -157,18 +196,20 @@ export default function CreateSiteForm({
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
onCreate?.({
|
||||
name: data.name,
|
||||
id: data.siteId,
|
||||
nice: data.niceId.toString(),
|
||||
mbIn: "0 MB",
|
||||
mbOut: "0 MB",
|
||||
mbIn:
|
||||
data.type == "wireguard" || data.type == "newt"
|
||||
? "0 MB"
|
||||
: "--",
|
||||
mbOut:
|
||||
data.type == "wireguard" || data.type == "newt"
|
||||
? "0 MB"
|
||||
: "--",
|
||||
orgId: orgId as string,
|
||||
type: data.type as any,
|
||||
online: false
|
||||
@@ -194,10 +235,10 @@ PersistentKeepalive = 5`
|
||||
: "";
|
||||
|
||||
// am I at http or https?
|
||||
let proto = "http:";
|
||||
if (typeof window !== "undefined") {
|
||||
proto = window.location.protocol;
|
||||
}
|
||||
let proto = "https:";
|
||||
// if (typeof window !== "undefined") {
|
||||
// proto = window.location.protocol;
|
||||
// }
|
||||
|
||||
const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${proto}//${siteDefaults?.endpoint}`;
|
||||
|
||||
@@ -245,12 +286,21 @@ PersistentKeepalive = 5`
|
||||
<SelectValue placeholder="Select method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="wireguard">
|
||||
WireGuard
|
||||
<SelectItem value="local">
|
||||
Local
|
||||
</SelectItem>
|
||||
<SelectItem value="newt">
|
||||
<SelectItem
|
||||
value="newt"
|
||||
disabled={!siteDefaults}
|
||||
>
|
||||
Newt
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="wireguard"
|
||||
disabled={!siteDefaults}
|
||||
>
|
||||
WireGuard
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
@@ -264,50 +314,76 @@ PersistentKeepalive = 5`
|
||||
|
||||
<div className="w-full">
|
||||
{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" &&
|
||||
isLoading ? (
|
||||
<p>Loading WireGuard configuration...</p>
|
||||
) : (
|
||||
<CopyTextBox text={newtConfig} wrapText={false} />
|
||||
)}
|
||||
) : form.watch("method") === "newt" ? (
|
||||
<>
|
||||
<CopyTextBox
|
||||
text={newtConfig}
|
||||
wrapText={false}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
You will only be able to see the
|
||||
configuration once.
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
You will only be able to see the configuration once.
|
||||
</span>
|
||||
|
||||
{form.watch("method") === "newt" && (
|
||||
<>
|
||||
<br />
|
||||
<Link
|
||||
className="text-sm text-primary flex items-center gap-1"
|
||||
href="https://docs.fossorial.io/Newt/install"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span>
|
||||
{" "}
|
||||
Learn how to install Newt on your system
|
||||
</span>
|
||||
<SquareArrowOutUpRight size={14} />
|
||||
</Link>
|
||||
</>
|
||||
<Link
|
||||
className="text-sm text-primary flex items-center gap-1"
|
||||
href="https://docs.fossorial.io/Newt/install"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span>
|
||||
{" "}
|
||||
Learn how to install Newt on your system
|
||||
</span>
|
||||
<SquareArrowOutUpRight size={14} />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<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"
|
||||
{form.watch("method") === "local" && (
|
||||
<Link
|
||||
className="text-sm text-primary flex items-center gap-1"
|
||||
href="https://docs.fossorial.io/Pangolin/without-tunneling"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
I have copied the config
|
||||
</label>
|
||||
</div>
|
||||
<span>
|
||||
{" "}
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ import { useState } from "react";
|
||||
import CreateSiteForm from "./CreateSiteForm";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
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 { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import CreateSiteFormModal from "./CreateSiteModal";
|
||||
@@ -146,21 +146,27 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
|
||||
if (originalRow.online) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
if (
|
||||
originalRow.type == "newt" ||
|
||||
originalRow.type == "wireguard"
|
||||
) {
|
||||
if (originalRow.online) {
|
||||
return (
|
||||
<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 {
|
||||
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>
|
||||
);
|
||||
return <span>--</span>;
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -245,6 +251,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
</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) {
|
||||
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 (
|
||||
<Alert>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">Site Information</AlertTitle>
|
||||
<AlertDescription className="mt-4">
|
||||
<InfoSections>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Status</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{site.online ? (
|
||||
<div 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>
|
||||
</div>
|
||||
) : (
|
||||
<div 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>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<Separator orientation="vertical" />
|
||||
{(site.type == "newt" || site.type == "wireguard") && (
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Status</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{site.online ? (
|
||||
<div 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>
|
||||
</div>
|
||||
) : (
|
||||
<div 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>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
|
||||
<Separator orientation="vertical" />
|
||||
</>
|
||||
)}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Connection Type</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{site.type === "newt"
|
||||
? "Newt"
|
||||
: site.type === "wireguard"
|
||||
? "WireGuard"
|
||||
: "Unknown"}
|
||||
{getConnectionTypeString(site.type)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
|
||||
@@ -23,7 +23,10 @@ export default async function SitesPage(props: SitesPageProps) {
|
||||
sites = res.data.data.sites;
|
||||
} 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) {
|
||||
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
|
||||
} else if (mb >= 1024) {
|
||||
@@ -38,8 +41,8 @@ export default async function SitesPage(props: SitesPageProps) {
|
||||
name: site.name,
|
||||
id: site.siteId,
|
||||
nice: site.niceId.toString(),
|
||||
mbIn: formatSize(site.megabytesIn || 0),
|
||||
mbOut: formatSize(site.megabytesOut || 0),
|
||||
mbIn: formatSize(site.megabytesIn || 0, site.type),
|
||||
mbOut: formatSize(site.megabytesOut || 0, site.type),
|
||||
orgId: params.orgId,
|
||||
type: site.type as any,
|
||||
online: site.online
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function AccessToken({
|
||||
redirectUrl
|
||||
}: AccessTokenProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
@@ -49,6 +50,7 @@ export default function AccessToken({
|
||||
});
|
||||
|
||||
if (res.data.data.session) {
|
||||
setIsValid(true);
|
||||
window.location.href = redirectUrl;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -61,24 +63,47 @@ export default function AccessToken({
|
||||
check();
|
||||
}, [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 ? (
|
||||
<div></div>
|
||||
) : (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-2xl font-bold">
|
||||
Access URL Invalid
|
||||
{renderTitle()}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<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>
|
||||
<CardContent>{renderContent()}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,11 +45,10 @@ export default async function ResourceAuthPage(props: {
|
||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
||||
|
||||
if (!authInfo) {
|
||||
{
|
||||
/* @ts-ignore */
|
||||
} // TODO: fix this
|
||||
// TODO: fix this
|
||||
return (
|
||||
<div className="w-full max-w-md">
|
||||
{/* @ts-ignore */}
|
||||
<ResourceNotFound />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ const buttonVariants = cva(
|
||||
secondary:
|
||||
"bg-secondary border border-input text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
text: "",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
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,
|
||||
externalPort: process.env.SERVER_EXTERNAL_PORT 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: {
|
||||
environment: process.env.ENVIRONMENT as string,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { pullEnv } from "./pullEnv";
|
||||
|
||||
export function constructShareLink(
|
||||
resourceId: number,
|
||||
id: string,
|
||||
@@ -5,3 +7,12 @@ export function constructShareLink(
|
||||
) {
|
||||
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;
|
||||
sessionCookieName: string;
|
||||
resourceSessionCookieName: string;
|
||||
resourceAccessTokenParam: string;
|
||||
},
|
||||
email: {
|
||||
emailEnabled: boolean;
|
||||
|
||||
Reference in New Issue
Block a user