Merge pull request #39 from fosrl/dev

local sites and direct share links
This commit is contained in:
Milo Schwartz 2025-01-12 16:12:50 -05:00 committed by GitHub
commit 302ac2e644
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 915 additions and 326 deletions

View file

@ -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"/>

View file

@ -12,6 +12,7 @@ server:
secure_cookies: false secure_cookies: false
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

View file

@ -9,9 +9,10 @@ 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
traefik: traefik:
cert_resolver: letsencrypt cert_resolver: letsencrypt

View file

@ -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

View file

@ -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"

View file

@ -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
)

View file

@ -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=

View file

@ -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.5"
config.GerbilVersion = "1.0.0-beta.1"
}
//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,12 +141,19 @@ 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")
pass2 := readPassword("Confirm admin user password")
if pass1 != pass2 {
fmt.Println("Passwords do not match")
} else {
config.AdminUserPassword = pass1
if valid, message := validatePassword(config.AdminUserPassword); valid { if valid, message := validatePassword(config.AdminUserPassword); valid {
break break
} else { } else {
@ -126,6 +165,7 @@ func collectUserInput(reader *bufio.Reader) Config {
fmt.Println("- At least one special character") fmt.Println("- At least one special character")
} }
} }
}
// Security settings // Security settings
fmt.Println("\n=== Security Settings ===") fmt.Println("\n=== Security Settings ===")
@ -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")

View file

@ -1,6 +1,6 @@
{ {
"name": "@fosrl/pangolin", "name": "@fosrl/pangolin",
"version": "1.0.0-beta.4", "version": "1.0.0-beta.5",
"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",

View 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;
}

View 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
};
}

View file

@ -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 });
}
}

View file

@ -32,7 +32,8 @@ const environmentSchema = z.object({
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()
}), }),
traefik: z.object({ traefik: z.object({
http_entrypoint: z.string(), http_entrypoint: z.string(),
@ -186,6 +187,7 @@ 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;
} }

View file

@ -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,

View file

@ -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,

View file

@ -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;
} }

View file

@ -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"
) )
); );
} }

View file

@ -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 = { await db.transaction(async (trx) => {
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, orgId,
exitNodeId, exitNodeId,
name, name,
niceId, niceId,
subnet, subnet,
type type,
}; ...(pubKey && type == "wireguard" && { pubKey })
})
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(); .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: []

View file

@ -123,6 +123,17 @@ export async function createTarget(
); );
} }
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 // make sure the target is within the site subnet
if ( if (
site.type == "wireguard" && site.type == "wireguard" &&
@ -177,7 +188,7 @@ export async function createTarget(
); );
} }
const newTarget = await db newTarget = await db
.insert(targets) .insert(targets)
.values({ .values({
resourceId, resourceId,
@ -207,6 +218,7 @@ export async function createTarget(
addTargets(newt.newtId, newTarget); addTargets(newt.newtId, newTarget);
} }
} }
}
return response<CreateTargetResponse>(res, { return response<CreateTargetResponse>(res, {
data: newTarget[0], data: newTarget[0],

View file

@ -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}`,
},
],
},
};
} }
} }

View file

@ -4,11 +4,13 @@ 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 { 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 +19,8 @@ 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 }
// Add new migrations here as they are created // Add new migrations here as they are created
] as const; ] as const;

View file

@ -0,0 +1,42 @@
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.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");
console.log("Done.");
}

View file

@ -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,

View file

@ -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>
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className="space-y-2"
>
<div className="mx-auto"> <div className="mx-auto">
<CopyTextBox <CopyTextBox
text={link} text={link}
wrapText={false} wrapText={false}
/> />
</div> </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>

View file

@ -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>

View file

@ -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,54 @@ 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,
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 +194,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
@ -245,12 +284,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,22 +312,31 @@ 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" && (
<>
<br />
<Link <Link
className="text-sm text-primary flex items-center gap-1" className="text-sm text-primary flex items-center gap-1"
href="https://docs.fossorial.io/Newt/install" href="https://docs.fossorial.io/Newt/install"
@ -292,9 +349,25 @@ PersistentKeepalive = 5`
</span> </span>
<SquareArrowOutUpRight size={14} /> <SquareArrowOutUpRight size={14} />
</Link> </Link>
</>
)} )}
{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"
>
<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"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
id="terms" id="terms"
@ -308,6 +381,7 @@ PersistentKeepalive = 5`
I have copied the config I have copied the config
</label> </label>
</div> </div>
)}
</form> </form>
</Form> </Form>
</div> </div>

View file

@ -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,7 +146,10 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
}, },
cell: ({ row }) => { cell: ({ row }) => {
const originalRow = row.original; const originalRow = row.original;
if (
originalRow.type == "newt" ||
originalRow.type == "wireguard"
) {
if (originalRow.online) { if (originalRow.online) {
return ( return (
<span className="text-green-500 flex items-center space-x-2"> <span className="text-green-500 flex items-center space-x-2">
@ -162,6 +165,9 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
</span> </span>
); );
} }
} else {
return <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>
);
}
} }
}, },
{ {

View file

@ -16,12 +16,26 @@ 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>
{(site.type == "newt" || site.type == "wireguard") && (
<>
<InfoSection> <InfoSection>
<InfoSectionTitle>Status</InfoSectionTitle> <InfoSectionTitle>Status</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
@ -38,15 +52,14 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
)} )}
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
<Separator orientation="vertical" /> <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>

View file

@ -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

View file

@ -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>
); );
} }

View file

@ -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>
); );

View file

@ -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: {

View 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 }

View file

@ -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,

View file

@ -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}`;
}

View file

@ -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;