Initial commit
Signed-off-by: Christoph Heiss <contact@christoph-heiss.at>
This commit is contained in:
commit
688d012309
3
.dockerignore
Normal file
3
.dockerignore
Normal file
|
@ -0,0 +1,3 @@
|
|||
/node_modules
|
||||
/data
|
||||
/.next
|
12
.editorconfig
Normal file
12
.editorconfig
Normal 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
57
.eslintrc.json
Normal 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
35
.gitignore
vendored
Normal 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
22
.stylelintrc.json
Normal 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
44
Dockerfile
Normal 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
34
README.md
Normal 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
69
create-user.js
Normal 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
BIN
data/database.sqlite3
Normal file
Binary file not shown.
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal 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
5
next-env.d.ts
vendored
Normal 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
12
next.config.js
Normal 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
10893
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
57
package.json
Normal file
57
package.json
Normal 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
6
postcss.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
63
src/components/base-layout.tsx
Normal file
63
src/components/base-layout.tsx
Normal 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,
|
||||
|
||||
<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
43
src/lib/database.ts
Normal 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
59
src/lib/utils.ts
Normal 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
336
src/lib/wireguard.ts
Normal 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
28
src/lib/withSession.ts
Normal 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
19
src/pages/_app.tsx
Normal 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
24
src/pages/_document.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
18
src/pages/api/interfaces.ts
Normal file
18
src/pages/api/interfaces.ts
Normal 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
60
src/pages/api/login.ts
Normal 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
19
src/pages/api/logout.ts
Normal 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
253
src/pages/index.tsx
Normal 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
78
src/pages/login.tsx
Normal 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
18
src/styles/globals.scss
Normal 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
1
src/types/blake2b.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
declare module 'blake2b';
|
5
src/types/iron-session.d.ts
vendored
Normal file
5
src/types/iron-session.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
declare module 'iron-session' {
|
||||
interface IronSessionData {
|
||||
user?: string;
|
||||
}
|
||||
}
|
26
tailwind.config.js
Normal file
26
tailwind.config.js
Normal 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
42
tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
Reference in a new issue