Initial commit

Signed-off-by: Christoph Heiss <contact@christoph-heiss.at>
This commit is contained in:
Christoph Heiss 2022-07-15 00:11:29 +02:00
commit 688d012309
Signed by: c8h4
GPG key ID: 9C82009BEEDEA0FF
32 changed files with 12350 additions and 0 deletions

3
.dockerignore Normal file
View file

@ -0,0 +1,3 @@
/node_modules
/data
/.next

12
.editorconfig Normal file
View file

@ -0,0 +1,12 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.scss]
indent_size = 4

57
.eslintrc.json Normal file
View file

@ -0,0 +1,57 @@
{
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"airbnb",
"next/core-web-vitals",
"eslint:recommended"
],
"env": {
"es2020": true,
"browser": true,
"node": true
},
"rules": {
"import/extensions": [
"error",
"ignorePackages",
{
"js": "never",
"jsx": "never",
"ts": "never",
"tsx": "never"
}
],
"lines-between-class-members": "off",
"jsx-a11y/anchor-is-valid": [
"error",
{
"components": ["Link"],
"specialLink": ["hrefLeft", "hrefRight"],
"aspects": ["invalidHref", "preferButton"]
}
],
"react/jsx-props-no-spreading": "off",
"react/jsx-filename-extension": [
"error",
{
"extensions": [".js", ".jsx", ".ts", ".tsx"]
}
],
"quotes": ["error", "single"],
"no-shadow": "off",
"@typescript-eslint/no-shadow": ["error"],
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error"],
"indent": "off",
"@typescript-eslint/indent": [
"error",
2,
{
"SwitchCase": 1
}
]
}
}

35
.gitignore vendored Normal file
View file

@ -0,0 +1,35 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo

22
.stylelintrc.json Normal file
View file

@ -0,0 +1,22 @@
{
"extends": "stylelint-config-standard-scss",
"rules": {
"declaration-empty-line-before": "never",
"indentation": 4,
"scss/dollar-variable-colon-space-before": null,
"selector-class-pattern": "[a-z][a-zA-Z0-9]+",
"string-quotes": "single",
"scss/at-rule-no-unknown": [
true,
{
"ignoreAtRules": [
"tailwind",
"apply",
"variants",
"responsive",
"screen"
]
}
]
}
}

44
Dockerfile Normal file
View file

@ -0,0 +1,44 @@
# Install dependencies only when needed
FROM node:17-alpine AS deps
WORKDIR /app
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
COPY package.json package-lock.json ./
RUN npm clean-install && npm install sharp
# Rebuild the source code only when needed
FROM node:17-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED 1
ARG GIT_COMMIT_SHA
RUN NEXT_PUBLIC_GIT_COMMIT_SHA=$GIT_COMMIT_SHA npm run build
# Production image, copy all the files and run next
FROM node:17-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/create-user.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
RUN mkdir ./data
EXPOSE 3000
CMD ["npm", "run", "start"]

34
README.md Normal file
View file

@ -0,0 +1,34 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

69
create-user.js Normal file
View file

@ -0,0 +1,69 @@
const crypto = require('node:crypto');
const blake2b = require('blake2b');
const sqlite3 = require('sqlite3');
async function getPassword() {
process.stdin.resume();
process.stdin.setEncoding('utf8');
process.stdin.setRawMode(true);
process.stdout.write('Password: ');
return new Promise((resolve, reject) => {
let password = '';
process.stdin.on('data', function(ch) {
switch (ch.toString()) {
case '\n':
case '\r':
case '\u0004': // EOF
process.stdin.setRawMode(false);
process.stdin.pause();
process.stdout.write('\n');
resolve(password);
break;
case '\u0003': // ^C
process.stdin.setRawMode(false);
process.stdin.pause();
process.stdout.write('\n');
reject('Cancelled');
break;
case '\u007f': // ^H
password = password.slice(0, -1);
break;
default:
password += ch;
break;
}
});
});
}
if (process.argv.length != 3) {
console.log('Invalid number of arguments!');
process.exit(1);
}
(async () => {
const username = process.argv[2];
const password = await getPassword();
const salt = crypto.randomBytes(blake2b.SALTBYTES);
const personal = Buffer.alloc(blake2b.PERSONALBYTES);
personal.write(username);
const hash = blake2b(blake2b.KEYBYTES_MAX, null, salt, personal)
.update(Buffer.from(password))
.digest('hex');
const db = new sqlite3.Database('./data/database.sqlite3');
db.run(
'INSERT INTO users (id, createdAt, updatedAt, password) VALUES (?, ?, ?, ?)',
[
username,
new Date().toISOString(),
new Date().toISOString(),
`blake2b:${blake2b.KEYBYTES_MAX}:${salt.toString('hex')}:${hash}`,
]
);
})().catch(console.log);

BIN
data/database.sqlite3 Normal file

Binary file not shown.

9
docker-compose.yml Normal file
View file

@ -0,0 +1,9 @@
version: '3'
services:
wgdash:
image: ghcr.io/christoph-heiss/wgdash:0.1.0
restart: unless-stopped
cap_add:
- NET_ADMIN
network_mode: host

5
next-env.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

12
next.config.js Normal file
View file

@ -0,0 +1,12 @@
const path = require('path');
/** @type {import('next').NextConfig} */
module.exports = {
reactStrictMode: true,
poweredByHeader: false,
sassOptions: {
includePaths: [
path.join(__dirname, 'src/styles'),
],
},
};

10893
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

57
package.json Normal file
View file

@ -0,0 +1,57 @@
{
"author": "Christoph Heiss <contact@christoph-heiss.at>",
"name": "wgdash",
"version": "0.1.0",
"private": true,
"repository": {
"type": "git",
"url": "git@github.com:christoph-heiss/wgdash.git"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint && tsc --noEmit && stylelint src/styles/**/*.scss",
"lint:tsx": "next lint",
"lint:types": "tsc --noEmit",
"lint:scss": "stylelint src/styles/**/*.scss",
"create-user": "node ./create-user.js"
},
"dependencies": {
"@js-temporal/polyfill": "^0.4.1",
"blake2b": "^2.1.4",
"iron-session": "^6.1.3",
"knex": "^2.1.0",
"netlink": "^0.2.2",
"next": "12.1.6",
"objection": "^3.0.1",
"preact": "^10.7.3",
"preact-render-to-string": "^5.2.0",
"react": "npm:@preact/compat@^17.1.1",
"react-dom": "npm:@preact/compat@^17.1.1",
"react-ssr-prepass": "npm:preact-ssr-prepass@^1.2.0",
"sqlite3": "^5.0.8"
},
"devDependencies": {
"@types/node": "^17.0.42",
"@types/react": "^17.0.39",
"@types/react-dom": "^17.0.11",
"@typescript-eslint/eslint-plugin": "^5.27.1",
"@typescript-eslint/parser": "^5.27.1",
"a17t": "^0.10.1",
"autoprefixer": "^10.4.7",
"eslint": "8.17.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-next": "12.1.6",
"loader-utils": "^3.2.0",
"sass": "^1.52.3",
"stylelint": "^14.9.1",
"stylelint-config-standard-scss": "^4.0.0",
"tailwindcss": "^3.1.2",
"typescript": "^4.7.3"
},
"browserslist": [
"last 2 versions",
"> 5%"
]
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -0,0 +1,63 @@
import { useState, ReactNode, SyntheticEvent } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { apiPost, classNames } from 'lib/utils';
interface BaseLayoutProps {
userName?: string;
children: ReactNode;
}
export default function BaseLayout({ userName, children }: BaseLayoutProps) {
const router = useRouter();
const [logoutLoading, setLogoutLoading] = useState(false);
const logout = (e: SyntheticEvent) => {
e.preventDefault();
setLogoutLoading(true);
apiPost('/api/logout')
.then(() => router.push('/'));
};
return (
<div className="max-w-screen-lg px-6 py-4 mx-auto lg:max-auto md:py-8">
<header className="relative flex">
<h3 className="flex-1 text-4xl text-neutral-800">
<Link href="/">
wgdash
</Link>
</h3>
{userName && (
<>
<h4 className="flex items-center">
Welcome,
&nbsp;
<span className="capitalize">
{userName}
</span>
</h4>
<h4 className="flex items-center ml-4">
<button
type="button"
className={classNames('button ~neutral @low', { loading: logoutLoading })}
onClick={logout}
>
Log out
</button>
</h4>
</>
)}
</header>
<hr className="sep h-16" />
<main>
{children}
</main>
</div>
);
}
BaseLayout.defaultProps = {
userName: undefined,
};

43
src/lib/database.ts Normal file
View file

@ -0,0 +1,43 @@
import path from 'node:path';
import { Model } from 'objection';
import Knex from 'knex';
export const knex = Knex({
client: 'sqlite3',
useNullAsDefault: true,
connection: {
filename: path.join(process.cwd(), 'data/database.sqlite3'),
},
});
Model.knex(knex);
export class User extends Model {
id!: string;
createdAt!: Date;
updatedAt!: Date;
deletedAt!: Date;
password!: string;
static get tableName() {
return 'users';
}
}
async function createUserSchema() {
if (await knex.schema.hasTable('users')) {
return;
}
await knex.schema.createTable('users', (table) => {
table.text('id').primary();
table.datetime('createdAt');
table.datetime('updatedAt');
table.datetime('deletedAt');
table.text('password'); // <alg>:<keylen>:<salt>:<hash>
});
}
(async () => {
await createUserSchema();
})();

59
src/lib/utils.ts Normal file
View file

@ -0,0 +1,59 @@
export function classNames(...args: any[]): string {
const classes = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
switch (typeof arg) {
case 'string':
classes.push(arg);
break;
case 'object':
Object.entries(arg)
.forEach(([key, value]) => {
if (value) {
classes.push(key);
}
});
break;
default:
break;
}
}
return classes.join(' ');
}
export async function apiPost(url: string, data?: any): Promise<any> {
return fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: data ? JSON.stringify(data) : '',
})
.then((res) => res.json())
.catch((err) => {
// eslint-disable-next-line no-console
console.log(err);
return Promise.resolve({});
});
}
export async function apiGet(url: string): Promise<any> {
return fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
},
})
.then((res) => res.json())
.catch((err) => {
// eslint-disable-next-line no-console
console.log(err);
return Promise.resolve({});
});
}

336
src/lib/wireguard.ts Normal file
View file

@ -0,0 +1,336 @@
import {
createRtNetlink,
createGenericNetlink,
parseAttribute,
genl,
formatAttribute,
parseAttributes,
FlagsGet,
rt,
NetlinkAttribute,
GenericNetlinkSocket,
} from 'netlink';
const WG_GENL_NAME = 'wireguard';
const WG_GENL_VERSION = 1;
const WG_CMD_GET_DEVICE = 0;
/* eslint-disable no-unused-vars */
enum AddressFamily {
INET = 2,
INET6 = 10,
}
enum WgDeviceAttribute {
UNSPEC,
IFINDEX,
IFNAME,
PRIVATE_KEY,
PUBLIC_KEY,
FLAGS,
LISTEN_PORT,
FWMARK,
PEERS,
}
enum WgPeerAttribute {
UNSPEC,
PUBLIC_KEY,
PRESHARED_KEY,
FLAGS,
ENDPOINT,
PERSISTENT_KEEPALIVE_INTERVAL,
LAST_HANDSHAKE_TIME,
RX_BYTES,
TX_BYTES,
ALLOWEDIPS,
PROTOCOL_VERSION,
}
enum WgAllowedIpAttribute {
UNSPEC,
FAMILY,
IPADDR,
CIDR_MASK,
}
/* eslint-enable no-unused-vars */
export type WireguardPeer = {
publicKey: string;
hasPresharedKey: boolean;
endpoint: string;
keepAlive: number;
lastHandshake: string;
rxBytes: number;
txBytes: number;
allowedIps: string[];
}
export type WireguardInterface = {
name: string;
index: number;
up: boolean;
rxBytes: number;
txBytes: number;
listenPort: number;
publicKey: string;
peers: WireguardPeer[];
}
type WgSpecificLinkInfo = Pick<WireguardInterface, 'listenPort' | 'publicKey' | 'peers'>;
function isWgLink(link: rt.LinkMessage): boolean {
if (link.attrs.linkinfo === undefined) {
return false;
}
const linkinfo = parseAttribute(link.attrs.linkinfo);
return linkinfo.x.data.compare(Buffer.from(`${WG_GENL_NAME}\x00`)) === 0;
}
/*
* struct in_addr {
* unsigned long s_addr;
* };
*/
function parseInAddr(family: AddressFamily, data: Buffer): string | null {
if (family === AddressFamily.INET) {
return [
data.readUInt8(0),
data.readUInt8(1),
data.readUInt8(2),
data.readUInt8(3),
].join('.');
}
if (family === AddressFamily.INET6) {
let addr = '';
for (let i = 0; i < 16; i += 2) {
addr += data.readUInt8(i).toString(16) + data.readUInt8(i + 1).toString(16);
if (i < 18) {
addr += ':';
}
}
return addr;
}
return null;
}
/*
* struct sockaddr_in {
* short sin_family;
* unsigned short sin_port;
* struct in_addr sin_addr;
* char sin_zero[8];
* };
*/
function parseSockAddrIn(data: Buffer): string {
const family = data.readInt16LE(0);
const port = data.readUint16LE(2);
const addr = parseInAddr(family, data.subarray(4, 20));
if (addr !== null) {
if (family === AddressFamily.INET) {
return `${addr}:${port}`;
}
if (family === AddressFamily.INET6) {
return `[${addr}]:${port}`;
}
}
return '<invalid>';
}
/*
*
* struct __kernel_timespec {
* __kernel_time64_t tv_sec;
* long long tv_nsec;
* };
*
*/
function parseLinuxTimespec(data: Buffer): string {
const sec = data.readBigUint64LE();
const nsec = data.readBigInt64LE(8);
const timestamp = sec * BigInt(1000) + nsec / BigInt(1000000);
return new Date(Number(timestamp)).toISOString();
}
function parseAllowedIp(data: Buffer): string {
let family = AddressFamily.INET;
let addr;
let mask;
parseAttributes(data, (item) => {
switch (item.type) {
case WgAllowedIpAttribute.FAMILY:
family = item.data.readUint16LE();
break;
case WgAllowedIpAttribute.IPADDR:
addr = parseInAddr(family, item.data);
break;
case WgAllowedIpAttribute.CIDR_MASK:
mask = item.data.readUInt8();
break;
default:
break;
}
});
return `${addr}/${mask}`;
}
function parseWgPeer(peerinfo: NetlinkAttribute): WireguardPeer {
const result: WireguardPeer = {
publicKey: '<unknown>',
hasPresharedKey: false,
endpoint: '<unknown>',
keepAlive: 0,
lastHandshake: new Date(0).toISOString(),
rxBytes: 0,
txBytes: 0,
allowedIps: [],
};
parseAttributes(peerinfo.data, (peer) => {
switch (peer.type) {
case WgPeerAttribute.PUBLIC_KEY:
result.publicKey = peer.data.toString('base64');
break;
case WgPeerAttribute.PRESHARED_KEY:
result.hasPresharedKey = peer.data.compare(Buffer.alloc(peer.data.length)) !== 0;
break;
case WgPeerAttribute.ENDPOINT:
result.endpoint = parseSockAddrIn(peer.data);
break;
case WgPeerAttribute.PERSISTENT_KEEPALIVE_INTERVAL:
result.keepAlive = peer.data.readUint16LE();
break;
case WgPeerAttribute.LAST_HANDSHAKE_TIME:
result.lastHandshake = parseLinuxTimespec(peer.data);
break;
case WgPeerAttribute.RX_BYTES:
result.rxBytes = Number(peer.data.readBigUint64LE());
break;
case WgPeerAttribute.TX_BYTES:
result.txBytes = Number(peer.data.readBigUint64LE());
break;
case WgPeerAttribute.ALLOWEDIPS:
parseAttributes(peer.data, (item) => {
result.allowedIps.push(parseAllowedIp(item.data));
});
break;
default:
break;
}
});
return result;
}
async function getWgFamilyId(socket: GenericNetlinkSocket): Promise<number> {
const families = await socket
.ctrlRequest(genl.Commands.GET_FAMILY, {}, { flags: FlagsGet.DUMP });
const family = families
.find((fam) => fam.familyName === WG_GENL_NAME && fam.version === WG_GENL_VERSION);
if (family === undefined || family.familyId === undefined) {
throw new Error('Failed to retrieve WireGuard family ID');
}
return family.familyId;
}
function unpackWgLinkInfo(info: any): WgSpecificLinkInfo {
const result: WgSpecificLinkInfo = {
listenPort: 0,
publicKey: '<unknown>',
peers: [],
};
if (info.length === 0 || info[0].length === 0) {
return result;
}
const { data } = info[0][0];
if (!Buffer.isBuffer(data)) {
return result;
}
parseAttributes(data, (item) => {
switch (item.type) {
case WgDeviceAttribute.LISTEN_PORT:
result.listenPort = item.data.readUint16LE();
break;
case WgDeviceAttribute.PUBLIC_KEY:
result.publicKey = item.data.toString('base64');
break;
case WgDeviceAttribute.PEERS:
parseAttributes(item.data, (peer) => result.peers.push(parseWgPeer(peer)));
break;
default:
break;
}
});
return result;
}
async function getWgLinkInfo(link: rt.LinkMessage): Promise<WireguardInterface> {
const socket = createGenericNetlink();
const familyId = await getWgFamilyId(socket)
.catch((err) => Promise.reject(new Error(`Failed to retrieve WireGuard family id: ${err}`)));
const message = formatAttribute({
type: WgDeviceAttribute.IFNAME,
data: Buffer.from(`${link.attrs.ifname}\x00`),
});
const response = await socket
.request(familyId, WG_CMD_GET_DEVICE, WG_GENL_VERSION, message, { flags: FlagsGet.DUMP })
.catch((err) => Promise.reject(new Error(`Failed to retrieve WireGuard link info: ${err}`)));
const wgInfo = unpackWgLinkInfo(response);
return {
name: link.attrs!.ifname!,
index: link.data!.index!,
up: link.data.flags!.up!,
rxBytes: Number(link.attrs.stats64!.rxBytes),
txBytes: Number(link.attrs.stats64!.txBytes),
...wgInfo,
};
}
export async function getWireguardInterfaces(): Promise<WireguardInterface[]> {
const socket = createRtNetlink();
return socket.getLinks()
.then((links) => (
links
.filter(isWgLink)
.map(getWgLinkInfo)
))
.then((ifs) => Promise.all(ifs));
}

28
src/lib/withSession.ts Normal file
View file

@ -0,0 +1,28 @@
import { withIronSessionApiRoute, withIronSessionSsr } from 'iron-session/next';
import {
GetServerSidePropsContext,
GetServerSidePropsResult,
NextApiHandler,
} from 'next';
const sessionOptions = {
cookieName: 'wgdash_user',
password: process.env.COOKIE_PASSWORD ?? 'developmentdevelopmentdevelopment',
cookieOptions: {
secure: process.env.NODE_ENV === 'production',
},
};
export function withSessionRoute(handler: NextApiHandler) {
return withIronSessionApiRoute(handler, sessionOptions);
}
export function withSessionSsr<
P extends { [key: string]: unknown } = { [key: string]: unknown },
>(
handler: (
context: GetServerSidePropsContext,
) => GetServerSidePropsResult<P> | Promise<GetServerSidePropsResult<P>>,
) {
return withIronSessionSsr(handler, sessionOptions);
}

19
src/pages/_app.tsx Normal file
View file

@ -0,0 +1,19 @@
import { AppProps } from 'next/app';
import Head from 'next/head';
import 'styles/globals.scss';
export default function WgDash({ Component, pageProps }: AppProps) {
if (typeof window !== 'undefined') {
// eslint-disable-next-line no-console
console.log(process.env.NEXT_PUBLIC_GIT_COMMIT_SHA);
}
return (
<>
<Head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</Head>
<Component {...pageProps} />
</>
);
}

24
src/pages/_document.tsx Normal file
View file

@ -0,0 +1,24 @@
import Document, {
Html, Head, Main, NextScript,
} from 'next/document';
export default class WgDashDocument extends Document {
render() {
return (
<Html lang="en">
<Head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="ie=edge" />
<meta name="author" content="Christoph Heiss" />
<meta name="apple-mobile-web-app-title" content="wgdash" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
</Head>
<body className="max-w-full overflow-x-hidden bg-inf">
<Main />
<NextScript />
</body>
</Html>
);
}
}

View file

@ -0,0 +1,18 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getWireguardInterfaces, WireguardInterface } from 'lib/wireguard';
import { withSessionRoute } from 'lib/withSession';
type Response = WireguardInterface[] | { error: string };
export default withSessionRoute(
async (req: NextApiRequest, res: NextApiResponse<Response>): Promise<Response | void> => {
if (!(req.session as any).user) {
res.status(401).end();
return Promise.resolve();
}
return getWireguardInterfaces()
.then((ifs) => res.status(200).json(ifs))
.catch((err) => res.status(500).json({ error: err.toString() }));
},
);

60
src/pages/api/login.ts Normal file
View file

@ -0,0 +1,60 @@
import { NextApiRequest, NextApiResponse } from 'next';
import blake2b from 'blake2b';
import { User } from 'lib/database';
import { withSessionRoute } from 'lib/withSession';
type Response = { success: boolean } | { error: string };
async function authenticateUser(username: string, password: string): Promise<User | null> {
const user = await User.query().findById(username);
if (!user) {
return null;
}
const [alg, keylen, salt, passwordHash] = user.password.split(':');
if (alg !== 'blake2b' || Number.isNaN(parseInt(keylen, 10))) {
throw new Error(`login: Unknown hash algorithm ${alg} with keylen ${keylen}`);
}
const saltBuffer = Buffer.from(salt, 'hex');
const personal = Buffer.alloc(blake2b.PERSONALBYTES);
personal.write(username);
const hash = blake2b(parseInt(keylen, 10), null, saltBuffer, personal)
.update(Buffer.from(password))
.digest('hex');
if (hash === passwordHash) {
return user;
}
return null;
}
export default withSessionRoute(
async (req: NextApiRequest, res: NextApiResponse<Response>) => {
if (req.method !== 'POST') {
res.setHeader('Allow', ['GET', 'PUT']);
res.status(405).json({ error: `Method ${req.method} not allowed` });
return;
}
const { username, password } = req.body;
if (!username || !password) {
res.status(400).json({ error: 'Username or password missing' });
return;
}
const user = await authenticateUser(username, password);
if (user === null) {
res.status(404).json({ error: 'Username or password wrong' });
return;
}
(req.session as any).user = user.id;
await req.session.save();
res.status(200).json({ success: true });
},
);

19
src/pages/api/logout.ts Normal file
View file

@ -0,0 +1,19 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { withSessionRoute } from 'lib/withSession';
type Response = { success: boolean } | { error: string };
export default withSessionRoute(
async (req: NextApiRequest, res: NextApiResponse<Response>) => {
if (req.method !== 'POST') {
res.setHeader('Allow', ['GET', 'PUT']);
res.status(405).json({ error: `Method ${req.method} not allowed` });
return;
}
delete (req.session as any).user;
await req.session.save();
res.status(200).json({ success: true });
},
);

253
src/pages/index.tsx Normal file
View file

@ -0,0 +1,253 @@
import { useEffect, useState } from 'react';
import BaseLayout from 'components/base-layout';
import { withSessionSsr } from 'lib/withSession';
import { apiGet } from 'lib/utils';
import type { WireguardInterface } from 'lib/wireguard';
import { Temporal } from '@js-temporal/polyfill';
function formatTraffic(b: number): string {
if (b < 1024 * 1024) {
return `${(b / 1024).toFixed(2)} KiB`;
}
if (b < 1024 * 1024 * 1024) {
return `${(b / 1024 / 1024).toFixed(2)} MiB`;
}
return `${(b / 1024 / 1024 / 1024).toFixed(2)} GiB`;
}
function formatTimeDelta(date: string): string {
let result = '';
try {
const from = Temporal.Instant.from(date).toZonedDateTimeISO(Temporal.Now.timeZone());
const delta = Temporal.Now.zonedDateTimeISO().since(from, {
largestUnit: 'day',
smallestUnit: 'second',
});
if (delta.days > 0) {
result += `${delta.days}d `;
}
if (delta.hours > 0) {
result += `${delta.hours}h `;
}
if (delta.minutes > 0) {
result += `${delta.minutes}min `;
}
if (delta.seconds > 0) {
result += `${delta.seconds}s `;
}
// return delta.toLocaleString('en-US', {
// hours: 'narrow',
// minutes: 'narrow',
// seconds: 'narrow',
// });
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
}
return result;
}
function formatKeepAlive(keepAlive: number): string {
return keepAlive !== 0 ? `${keepAlive}s` : 'disabled';
}
interface IndexProps {
user: string;
}
export default function Index({ user }: IndexProps) {
const [interfaces, setInterfaces] = useState<WireguardInterface[]>([]);
const [fetchFailed, setFetchFailed] = useState(false);
const fetchData = async () => {
const res = await apiGet('/api/interfaces');
if (typeof res.error === 'string') {
setFetchFailed(true);
} else {
setFetchFailed(false);
setInterfaces(res);
}
};
useEffect(() => {
fetchData();
const handle = setInterval(fetchData, 2000);
return () => clearInterval(handle);
}, []);
return (
<BaseLayout userName={user}>
{fetchFailed && (
<>
<div className="card ~critical @high">
<strong>
Error:
</strong>
{' '}
Failed to fetch WireGuard info. Retrying in 2 seconds ...
</div>
<hr className="sep" />
</>
)}
{interfaces.map((wgif) => (
<div className="md:flex" key={wgif.index}>
<aside className="w-2/12 content">
<h2 style={{ marginBottom: -5 }}>
{wgif.name}
</h2>
{wgif.up
? <div className="chip ~positive">Up</div>
: <div className="chip ~critical">Down</div>}
</aside>
<div className="md:w-10/12 content">
<div className="flex">
<div className="grow">
<h4>
Listen port
</h4>
{wgif.listenPort}
</div>
<div className="grow">
<h4>
RX traffic
</h4>
{formatTraffic(wgif.rxBytes)}
</div>
<div className="grow">
<h4>
TX traffic
</h4>
{formatTraffic(wgif.txBytes)}
</div>
</div>
<div>
<h4>
Public key
</h4>
<code>
{wgif.publicKey}
</code>
</div>
{wgif.peers.map((peer) => (
<div key={peer.publicKey} className="card">
<span className="chip ~neutral mr-2">
Peer
</span>
{peer.hasPresharedKey
? (
<span className="chip ~positive">
Has preshared key
</span>
) : (
<span className="chip ~critical">
No preshared key
</span>
)}
<hr className="sep h-2" />
<div className="flex mb-4">
<div className="basis-1/4">
<h4>
Endpoint
</h4>
{peer.endpoint}
</div>
<div className="basis-1/2">
<h4>
RX traffic
</h4>
{formatTraffic(peer.rxBytes)}
</div>
<div className="basis-1/4">
<h4>
TX traffic
</h4>
{formatTraffic(peer.txBytes)}
</div>
</div>
<div className="flex mb-4">
<div className="basis-1/4">
<h4>
Keep alive
</h4>
{formatKeepAlive(peer.keepAlive)}
</div>
<div className="basis-1/2">
<h4>
Last handshake
</h4>
{formatTimeDelta(peer.lastHandshake)}
{' '}
ago
</div>
<div className="basis-1/4">
<h4>
Allowed IPs
</h4>
{peer.allowedIps.map((ip) => (
<>
<span>{ip}</span>
<br />
</>
))}
</div>
</div>
<div>
<h4>
Public key
</h4>
<code>
{peer.publicKey}
</code>
</div>
</div>
))}
</div>
</div>
))}
</BaseLayout>
);
}
export const getServerSideProps = withSessionSsr(
async ({ req }) => {
const { user } = req.session as any;
if (user === undefined) {
return {
redirect: {
permanent: false,
destination: '/login',
},
props: {},
};
}
return {
props: {
user,
},
};
},
);

78
src/pages/login.tsx Normal file
View file

@ -0,0 +1,78 @@
import { useState, SyntheticEvent } from 'react';
import { useRouter } from 'next/router';
import BaseLayout from 'components/base-layout';
import { apiPost, classNames } from 'lib/utils';
export default function Login() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [failedLogin, setFailedLogin] = useState(false);
const submit = async (e: SyntheticEvent) => {
e.preventDefault();
setFailedLogin(false);
setLoading(true);
const res = await apiPost('/api/login', { username, password });
if (res.success) {
router.push('/');
} else {
setFailedLogin(true);
}
setLoading(false);
};
const submitBtnClasses = classNames(
'button button-xl min-w-full ~info @high',
{
loading,
},
);
return (
<BaseLayout>
{failedLogin && (
<div className="card ~warning @high font-bold max-w-xl xl:mx-auto mb-4">
Incorrect username or password.
</div>
)}
<form className="border rounded-lg max-w-xl xl:mx-auto">
<section className="section ~neutral p-6">
<label htmlFor="login-form-username" className="label">
Username
<input
id="login-form-username"
className="field mt-2 mb-4"
type="text"
placeholder="admin"
value={username}
onInput={(e) => setUsername((e.target as HTMLInputElement).value)}
/>
</label>
<label htmlFor="login-form-password" className="label">
Password
<input
id="login-form-password"
className="field mt-2"
type="password"
placeholder="hunter2"
minLength={8}
value={password}
onInput={(e) => setPassword((e.target as HTMLInputElement).value)}
/>
</label>
</section>
<section className="section p-4">
<button className={submitBtnClasses} type="submit" onClick={submit}>
Sign In
</button>
</section>
</form>
</BaseLayout>
);
}

18
src/styles/globals.scss Normal file
View file

@ -0,0 +1,18 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
font-family:
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
}

1
src/types/blake2b.d.ts vendored Normal file
View file

@ -0,0 +1 @@
declare module 'blake2b';

5
src/types/iron-session.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
declare module 'iron-session' {
interface IronSessionData {
user?: string;
}
}

26
tailwind.config.js Normal file
View file

@ -0,0 +1,26 @@
const colors = require('tailwindcss/colors');
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/{components,pages}/**/*.tsx',
],
theme: {
extend: {
colors: {
neutral: colors.slate,
positive: colors.green,
urge: colors.violet,
warning: colors.yellow,
info: colors.blue,
critical: colors.red,
},
minWidth: {
'1/2': '50%',
},
},
},
plugins: [
require('a17t'),
],
};

42
tsconfig.json Normal file
View file

@ -0,0 +1,42 @@
{
"compilerOptions": {
"baseUrl": "src",
"target": "es2020",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"paths": {
"@public/*": [
"../public/*"
]
},
"typeRoots": [
"types",
"../node_modules/@types"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"emitDecoratorMetadata": true,
"experimentalDecorators": true
},
"include": [
"next-env.d.ts",
"src/**/*.ts",
"src/**/*.tsx"
],
"exclude": [
"node_modules"
]
}