Merge branch 'main' into dev

This commit is contained in:
Owen
2026-03-30 15:53:46 -07:00
23 changed files with 489 additions and 405 deletions

View File

@@ -415,7 +415,7 @@ jobs:
- name: Install cosign - name: Install cosign
# cosign is used to sign and verify container images (key and keyless) # cosign is used to sign and verify container images (key and keyless)
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- name: Dual-sign and verify (GHCR & Docker Hub) - name: Dual-sign and verify (GHCR & Docker Hub)
# Sign each image by digest using keyless (OIDC) and key-based signing, # Sign each image by digest using keyless (OIDC) and key-based signing,

View File

@@ -23,7 +23,7 @@ jobs:
skopeo --version skopeo --version
- name: Install cosign - name: Install cosign
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- name: Input check - name: Input check
run: | run: |

View File

@@ -99,11 +99,6 @@ func ReadAppConfig(configPath string) (*AppConfigValues, error) {
return values, nil return values, nil
} }
// findPattern finds the start of a pattern in a string
func findPattern(s, pattern string) int {
return bytes.Index([]byte(s), []byte(pattern))
}
func copyDockerService(sourceFile, destFile, serviceName string) error { func copyDockerService(sourceFile, destFile, serviceName string) error {
// Read source file // Read source file
sourceData, err := os.ReadFile(sourceFile) sourceData, err := os.ReadFile(sourceFile)
@@ -187,7 +182,7 @@ func backupConfig() error {
return nil return nil
} }
func MarshalYAMLWithIndent(data any, indent int) ([]byte, error) { func MarshalYAMLWithIndent(data any, indent int) (resp []byte, err error) {
buffer := new(bytes.Buffer) buffer := new(bytes.Buffer)
encoder := yaml.NewEncoder(buffer) encoder := yaml.NewEncoder(buffer)
encoder.SetIndent(indent) encoder.SetIndent(indent)
@@ -196,7 +191,12 @@ func MarshalYAMLWithIndent(data any, indent int) ([]byte, error) {
return nil, err return nil, err
} }
defer encoder.Close() defer func() {
if cerr := encoder.Close(); cerr != nil && err == nil {
err = cerr
}
}()
return buffer.Bytes(), nil return buffer.Bytes(), nil
} }

View File

@@ -81,11 +81,17 @@ entryPoints:
transport: transport:
respondingTimeouts: respondingTimeouts:
readTimeout: "30m" readTimeout: "30m"
http3:
advertisedPort: 443
http: http:
tls: tls:
certResolver: "letsencrypt" certResolver: "letsencrypt"
middlewares: encodedCharacters:
- crowdsec@file allowEncodedSlash: true
allowEncodedQuestionMark: true
serversTransport: serversTransport:
insecureSkipVerify: true insecureSkipVerify: true
ping:
entryPoint: "web"

View File

@@ -38,6 +38,7 @@ services:
- 51820:51820/udp - 51820:51820/udp
- 21820:21820/udp - 21820:21820/udp
- 443:443 - 443:443
- 443:443/udp # For http3 QUIC if desired
- 80:80 - 80:80
{{end}} {{end}}
traefik: traefik:

View File

@@ -40,6 +40,8 @@ entryPoints:
transport: transport:
respondingTimeouts: respondingTimeouts:
readTimeout: "30m" readTimeout: "30m"
http3:
advertisedPort: 443
http: http:
tls: tls:
certResolver: "letsencrypt" certResolver: "letsencrypt"

View File

@@ -3,7 +3,7 @@ module installer
go 1.25.0 go 1.25.0
require ( require (
github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
golang.org/x/term v0.41.0 golang.org/x/term v0.41.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1

View File

@@ -14,8 +14,8 @@ github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGs
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=

View File

@@ -85,33 +85,6 @@ func readString(prompt string, defaultValue string) string {
return value return value
} }
func readStringNoDefault(prompt string) string {
var value string
for {
input := huh.NewInput().
Title(prompt).
Value(&value).
Validate(func(s string) error {
if s == "" {
return fmt.Errorf("this field is required")
}
return nil
})
err := runField(input)
handleAbort(err)
if value != "" {
// Print the answer so it remains visible in terminal history
if !isAccessibleMode() {
fmt.Printf("%s: %s\n", prompt, value)
}
return value
}
}
}
func readPassword(prompt string) string { func readPassword(prompt string) string {
var value string var value string

View File

@@ -8,7 +8,6 @@ import (
"io" "io"
"io/fs" "io/fs"
"net" "net"
"net/http"
"net/url" "net/url"
"os" "os"
"os/exec" "os/exec"
@@ -549,9 +548,9 @@ func createConfigFiles(config Config) error {
} }
// Walk through all embedded files // Walk through all embedded files
err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, err error) error { err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, walkErr error) (err error) {
if err != nil { if walkErr != nil {
return err return walkErr
} }
// Skip the root fs directory itself // Skip the root fs directory itself
@@ -602,7 +601,11 @@ func createConfigFiles(config Config) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to create %s: %v", path, err) return fmt.Errorf("failed to create %s: %v", path, err)
} }
defer outFile.Close() defer func() {
if cerr := outFile.Close(); cerr != nil && err == nil {
err = cerr
}
}()
// Execute template // Execute template
if err := tmpl.Execute(outFile, config); err != nil { if err := tmpl.Execute(outFile, config); err != nil {
@@ -618,18 +621,26 @@ func createConfigFiles(config Config) error {
return nil return nil
} }
func copyFile(src, dst string) error { func copyFile(src, dst string) (err error) {
source, err := os.Open(src) source, err := os.Open(src)
if err != nil { if err != nil {
return err return err
} }
defer source.Close() defer func() {
if cerr := source.Close(); cerr != nil && err == nil {
err = cerr
}
}()
destination, err := os.Create(dst) destination, err := os.Create(dst)
if err != nil { if err != nil {
return err return err
} }
defer destination.Close() defer func() {
if cerr := destination.Close(); cerr != nil && err == nil {
err = cerr
}
}()
_, err = io.Copy(destination, source) _, err = io.Copy(destination, source)
return err return err
@@ -741,32 +752,6 @@ func generateRandomSecretKey() string {
return base64.StdEncoding.EncodeToString(secret) return base64.StdEncoding.EncodeToString(secret)
} }
func getPublicIP() string {
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Get("https://ifconfig.io/ip")
if err != nil {
return ""
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return ""
}
ip := strings.TrimSpace(string(body))
// Validate that it's a valid IP address
if net.ParseIP(ip) != nil {
return ip
}
return ""
}
// Run external commands with stdio/stderr attached. // Run external commands with stdio/stderr attached.
func run(name string, args ...string) error { func run(name string, args ...string) error {
cmd := exec.Command(name, args...) cmd := exec.Command(name, args...)

641
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -92,12 +92,12 @@
"lucide-react": "0.577.0", "lucide-react": "0.577.0",
"maxmind": "5.0.5", "maxmind": "5.0.5",
"moment": "2.30.1", "moment": "2.30.1",
"next": "15.5.12", "next": "15.5.14",
"next-intl": "4.8.3", "next-intl": "4.8.3",
"next-themes": "0.4.6", "next-themes": "0.4.6",
"nextjs-toploader": "3.9.17", "nextjs-toploader": "3.9.17",
"node-cache": "5.1.2", "node-cache": "5.1.2",
"nodemailer": "8.0.1", "nodemailer": "8.0.4",
"oslo": "1.2.1", "oslo": "1.2.1",
"pg": "8.20.0", "pg": "8.20.0",
"posthog-node": "5.28.0", "posthog-node": "5.28.0",
@@ -125,7 +125,7 @@
"winston": "3.19.0", "winston": "3.19.0",
"winston-daily-rotate-file": "5.0.0", "winston-daily-rotate-file": "5.0.0",
"ws": "8.19.0", "ws": "8.19.0",
"yaml": "2.8.2", "yaml": "2.8.3",
"yargs": "18.0.0", "yargs": "18.0.0",
"zod": "4.3.6", "zod": "4.3.6",
"zod-validation-error": "5.0.0" "zod-validation-error": "5.0.0"
@@ -134,7 +134,7 @@
"@dotenvx/dotenvx": "1.54.1", "@dotenvx/dotenvx": "1.54.1",
"@esbuild-plugins/tsconfig-paths": "0.1.2", "@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/preview-server": "5.2.10", "@react-email/preview-server": "5.2.10",
"@tailwindcss/postcss": "4.2.1", "@tailwindcss/postcss": "4.2.2",
"@tanstack/react-query-devtools": "5.91.3", "@tanstack/react-query-devtools": "5.91.3",
"@types/better-sqlite3": "7.6.13", "@types/better-sqlite3": "7.6.13",
"@types/cookie-parser": "1.4.10", "@types/cookie-parser": "1.4.10",
@@ -160,21 +160,21 @@
"@types/yargs": "17.0.35", "@types/yargs": "17.0.35",
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "1.0.0",
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"esbuild": "0.27.3", "esbuild": "0.27.4",
"esbuild-node-externals": "1.20.1", "esbuild-node-externals": "1.20.1",
"eslint": "10.0.3", "eslint": "10.0.3",
"eslint-config-next": "16.1.7", "eslint-config-next": "16.1.7",
"postcss": "8.5.8", "postcss": "8.5.8",
"prettier": "3.8.1", "prettier": "3.8.1",
"react-email": "5.2.10", "react-email": "5.2.10",
"tailwindcss": "4.2.1", "tailwindcss": "4.2.2",
"tsc-alias": "1.8.16", "tsc-alias": "1.8.16",
"tsx": "4.21.0", "tsx": "4.21.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.56.1" "typescript-eslint": "8.56.1"
}, },
"overrides": { "overrides": {
"esbuild": "0.27.3", "esbuild": "0.27.4",
"dompurify": "3.3.2" "dompurify": "3.3.2"
} }
} }

View File

@@ -292,7 +292,8 @@ export const users = pgTable("user", {
termsVersion: varchar("termsVersion"), termsVersion: varchar("termsVersion"),
marketingEmailConsent: boolean("marketingEmailConsent").default(false), marketingEmailConsent: boolean("marketingEmailConsent").default(false),
serverAdmin: boolean("serverAdmin").notNull().default(false), serverAdmin: boolean("serverAdmin").notNull().default(false),
lastPasswordChange: bigint("lastPasswordChange", { mode: "number" }) lastPasswordChange: bigint("lastPasswordChange", { mode: "number" }),
locale: varchar("locale")
}); });
export const newts = pgTable("newt", { export const newts = pgTable("newt", {

View File

@@ -332,7 +332,8 @@ export const users = sqliteTable("user", {
serverAdmin: integer("serverAdmin", { mode: "boolean" }) serverAdmin: integer("serverAdmin", { mode: "boolean" })
.notNull() .notNull()
.default(false), .default(false),
lastPasswordChange: integer("lastPasswordChange") lastPasswordChange: integer("lastPasswordChange"),
locale: text("locale")
}); });
export const securityKeys = sqliteTable("webauthnCredentials", { export const securityKeys = sqliteTable("webauthnCredentials", {

View File

@@ -796,6 +796,11 @@ unauthenticated.get(
// ); // );
unauthenticated.get("/user", verifySessionMiddleware, user.getUser); unauthenticated.get("/user", verifySessionMiddleware, user.getUser);
unauthenticated.post(
"/user/locale",
verifySessionMiddleware,
user.updateUserLocale
);
unauthenticated.get("/my-device", verifySessionMiddleware, user.myDevice); unauthenticated.get("/my-device", verifySessionMiddleware, user.myDevice);
authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers); authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers);

View File

@@ -9,6 +9,7 @@ import { buildClientConfigurationForNewtClient } from "./buildConfiguration";
import { convertTargetsIfNessicary } from "../client/targets"; import { convertTargetsIfNessicary } from "../client/targets";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
<<<<<<< HEAD
const inputSchema = z.object({ const inputSchema = z.object({
publicKey: z.string(), publicKey: z.string(),
port: z.int().positive(), port: z.int().positive(),
@@ -17,6 +18,8 @@ const inputSchema = z.object({
type Input = z.infer<typeof inputSchema>; type Input = z.infer<typeof inputSchema>;
=======
>>>>>>> main
export const handleGetConfigMessage: MessageHandler = async (context) => { export const handleGetConfigMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context; const { message, client, sendToClient } = context;
const newt = client as Newt; const newt = client as Newt;
@@ -35,6 +38,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
return; return;
} }
<<<<<<< HEAD
const parsed = inputSchema.safeParse(message.data); const parsed = inputSchema.safeParse(message.data);
if (!parsed.success) { if (!parsed.success) {
logger.error( logger.error(
@@ -45,6 +49,9 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
} }
const { publicKey, port, chainId } = message.data as Input; const { publicKey, port, chainId } = message.data as Input;
=======
const { publicKey, port, chainId } = message.data;
>>>>>>> main
const siteId = newt.siteId; const siteId = newt.siteId;
// Get the current site data // Get the current site data

View File

@@ -33,7 +33,7 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
return; return;
} }
const { noCloud } = message.data; const { noCloud, chainId } = message.data;
const exitNodesList = await listExitNodes( const exitNodesList = await listExitNodes(
site.orgId, site.orgId,
@@ -98,7 +98,8 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
message: { message: {
type: "newt/ping/exitNodes", type: "newt/ping/exitNodes",
data: { data: {
exitNodes: filteredExitNodes exitNodes: filteredExitNodes,
chainId: chainId
} }
}, },
broadcast: false, // Send to all clients broadcast: false, // Send to all clients

View File

@@ -20,7 +20,8 @@ async function queryUser(userId: string) {
emailVerified: users.emailVerified, emailVerified: users.emailVerified,
serverAdmin: users.serverAdmin, serverAdmin: users.serverAdmin,
idpName: idp.name, idpName: idp.name,
idpId: users.idpId idpId: users.idpId,
locale: users.locale
}) })
.from(users) .from(users)
.leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idp, eq(users.idpId, idp.idpId))

View File

@@ -17,4 +17,5 @@ export * from "./createOrgUser";
export * from "./adminUpdateUser2FA"; export * from "./adminUpdateUser2FA";
export * from "./adminGetUser"; export * from "./adminGetUser";
export * from "./updateOrgUser"; export * from "./updateOrgUser";
export * from "./updateUserLocale";
export * from "./myDevice"; export * from "./myDevice";

View File

@@ -63,7 +63,8 @@ export async function myDevice(
emailVerified: users.emailVerified, emailVerified: users.emailVerified,
serverAdmin: users.serverAdmin, serverAdmin: users.serverAdmin,
idpName: idp.name, idpName: idp.name,
idpId: users.idpId idpId: users.idpId,
locale: users.locale
}) })
.from(users) .from(users)
.leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idp, eq(users.idpId, idp.idpId))

View File

@@ -0,0 +1,57 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { users } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
const bodySchema = z.strictObject({
locale: z.string().min(2).max(10)
});
export async function updateUserLocale(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const userId = req.user?.userId;
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not found")
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { locale } = parsedBody.data;
await db.update(users).set({ locale }).where(eq(users.userId, userId));
return response(res, {
data: null,
success: true,
error: false,
message: "User locale updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -12,6 +12,8 @@ import clsx from "clsx";
import { useTransition } from "react"; import { useTransition } from "react";
import { Locale } from "@/i18n/config"; import { Locale } from "@/i18n/config";
import { setUserLocale } from "@/services/locale"; import { setUserLocale } from "@/services/locale";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
type Props = { type Props = {
defaultValue: string; defaultValue: string;
@@ -25,12 +27,17 @@ export default function LocaleSwitcherSelect({
label label
}: Props) { }: Props) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const api = createApiClient(useEnvContext());
function onChange(value: string) { function onChange(value: string) {
const locale = value as Locale; const locale = value as Locale;
startTransition(() => { startTransition(() => {
setUserLocale(locale); setUserLocale(locale);
}); });
// Persist locale to the database (fire-and-forget)
api.post("/user/locale", { locale }).catch(() => {
// Silently ignore errors — cookie is already set as fallback
});
} }
const selected = items.find((item) => item.value === defaultValue); const selected = items.find((item) => item.value === defaultValue);

View File

@@ -2,10 +2,13 @@
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
import { Locale, defaultLocale, locales } from "@/i18n/config"; import { Locale, defaultLocale, locales } from "@/i18n/config";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
// In this example the locale is read from a cookie. You could alternatively // In this example the locale is read from a cookie. You could alternatively
// also read it from a database, backend service, or any other source. // also read it from a database, backend service, or any other source.
const COOKIE_NAME = "NEXT_LOCALE"; const COOKIE_NAME = "NEXT_LOCALE";
const COOKIE_MAX_AGE = 365 * 24 * 60 * 60; // 1 year in seconds
export async function getUserLocale(): Promise<Locale> { export async function getUserLocale(): Promise<Locale> {
const cookieLocale = (await cookies()).get(COOKIE_NAME)?.value; const cookieLocale = (await cookies()).get(COOKIE_NAME)?.value;
@@ -14,6 +17,23 @@ export async function getUserLocale(): Promise<Locale> {
return cookieLocale as Locale; return cookieLocale as Locale;
} }
// No cookie found — try to restore from user's saved locale in DB
try {
const res = await internal.get("/user", await authCookieHeader());
const userLocale = res.data?.data?.locale;
if (userLocale && locales.includes(userLocale as Locale)) {
// Set the cookie so subsequent requests don't need the API call
(await cookies()).set(COOKIE_NAME, userLocale, {
maxAge: COOKIE_MAX_AGE,
path: "/",
sameSite: "lax"
});
return userLocale as Locale;
}
} catch {
// User not logged in or API unavailable — fall through
}
const headerList = await headers(); const headerList = await headers();
const acceptLang = headerList.get("accept-language"); const acceptLang = headerList.get("accept-language");
@@ -33,5 +53,9 @@ export async function getUserLocale(): Promise<Locale> {
} }
export async function setUserLocale(locale: Locale) { export async function setUserLocale(locale: Locale) {
(await cookies()).set(COOKIE_NAME, locale); (await cookies()).set(COOKIE_NAME, locale, {
maxAge: COOKIE_MAX_AGE,
path: "/",
sameSite: "lax"
});
} }