initial commit

This commit is contained in:
2025-11-14 20:38:30 +01:00
parent ff91337962
commit ce1710dbd2
22 changed files with 1055 additions and 1 deletions

View File

@@ -0,0 +1,99 @@
import z from "zod";
const ResponseBody = z.object({
code: z.number(),
msg: z.string(),
data: z.any(),
});
const CODE_SUCCESS = z.literal(0);
export const NanoKVMClientOptions = z.object({
/** host of nanokvm */
host: z.string(),
/** the nanokvm username */
username: z.string(),
/** the nanokvm password */
password: z.string(),
/** password secret, optional.
* only needs to be set if a different secret is set on the nanokvm for more secure password encryption. */
passwordSecret: z.string().default("nanokvm-sipeed-2024"),
/** https isn't usable by default so we use http */
protocol: z.enum(["http", "https"]).default("http"),
/** You can provide a token which can improve reliability */
token: z.string().optional(),
});
// POST - /api/auth/login
export const AuthLoginSchemaSuccess = ResponseBody.extend({
code: CODE_SUCCESS,
data: z.object({
token: z.string(),
}),
});
export const AuthLoginSchemaInvalidPassword = ResponseBody.extend({
code: z.literal(-2),
});
export const AuthLoginSchema = z.discriminatedUnion("code", [
AuthLoginSchemaSuccess,
AuthLoginSchemaInvalidPassword,
]);
// GET - /api/vm/info
export const InfoSchema = z.object({
ips: z.array(
z.object({
name: z.string(),
addr: z.string(),
version: z.string(),
type: z.string(),
}),
),
mdns: z.string(),
image: z.string(),
application: z.string(),
deviceKey: z.string(),
});
export const InfoSuccess = ResponseBody.extend({
code: CODE_SUCCESS,
data: InfoSchema,
});
// GET - /api/vm/gpio
export const GpioSchema = z.object({
pwr: z.boolean(),
hdd: z.boolean(),
});
export const GpioSuccess = ResponseBody.extend({
code: CODE_SUCCESS,
data: GpioSchema,
});
// POST - /api/vm/gpio
export const TriggerPowerError = ResponseBody.extend({
code: z.literal(-1),
data: z.null(),
});
export const TriggerPowerSuccess = ResponseBody.extend({
code: CODE_SUCCESS,
data: z.null(),
});
export const TriggerPowerSchema = z.discriminatedUnion("code", [
TriggerPowerSuccess,
TriggerPowerError,
]);
export const TriggerPowerInput = z.object({
type: z.literal("power").default("power"),
duration: z.number().min(0).max(20000).default(800),
});

View File

@@ -0,0 +1,90 @@
import type { BodyInit } from "bun";
import type z from "zod";
import { encryptPassword } from "@/encryption";
import { logger } from "@/logger";
import {
type AuthLoginSchema,
NanoKVMClientOptions,
} from "./nano-kvm-client.schema";
export class NanoKVMClient {
private baseUrl: URL;
private options: z.output<typeof NanoKVMClientOptions>;
private token: string | undefined;
constructor(options: z.input<typeof NanoKVMClientOptions>) {
this.options = NanoKVMClientOptions.parse(options);
this.baseUrl = new URL(`${this.options.protocol}://${this.options.host}`);
}
get authUrl() {
return new URL("/api/auth/login", this.baseUrl);
}
async init() {
if (this.options.token) {
this.token = this.options.token;
logger.info("sucessfully initialized nanokvm using token from config");
return;
}
this.loginAndSetToken();
}
async fetch(path: string, method?: "POST" | "GET", body?: BodyInit) {
return fetch(new URL(path, this.baseUrl), {
headers: {
cookie: `nano-kvm-token=${this.token}`,
},
body,
method,
});
}
/** Calls the login api and stores the returned token */
async loginAndSetToken() {
const response = await fetch(this.authUrl, {
headers: {
"Content-Type": "application/json",
},
method: "POST",
body: JSON.stringify({
username: this.options.username,
password: encryptPassword(
this.options.password,
this.options.passwordSecret,
),
}),
});
const result = (await response.json()) as z.output<
typeof AuthLoginSchema
> | null;
if (result === null) {
throw new Error("No response from api!");
}
switch (result.code) {
case 0: {
logger.debug(`Got token ${result.data.token}`);
this.token = result.data.token;
return;
}
case -2: {
logger.fatal(`Invalid password! Failed to get token.`);
return;
}
default: {
logger.fatal(
"Unknown error while getting token.",
(result as z.output<typeof AuthLoginSchema>).msg,
);
}
}
}
}

View File

@@ -0,0 +1,64 @@
import type z from "zod";
import { NanoKVMClient } from "./nano-kvm-client";
import {
type GpioSchema,
GpioSuccess,
type InfoSchema,
InfoSuccess,
type NanoKVMClientOptions,
TriggerPowerInput,
TriggerPowerSchema,
} from "./nano-kvm-client.schema";
export class NanoKVMService {
private client: NanoKVMClient;
constructor(options: {
clientOptions: z.input<typeof NanoKVMClientOptions>;
}) {
this.client = new NanoKVMClient(options.clientOptions);
}
async init() {
await this.client.init();
}
async getInfo(): Promise<z.output<typeof InfoSchema>> {
const data = await (await this.client.fetch("/api/vm/info", "GET")).json();
return InfoSuccess.parse(data).data;
}
async getGpio(): Promise<z.output<typeof GpioSchema>> {
const response = await this.client.fetch("/api/vm/gpio", "GET");
const data = await response.json();
return GpioSuccess.parse(data).data;
}
async triggerPower(input: z.input<typeof TriggerPowerInput>) {
const parsedInput = TriggerPowerInput.parse(input);
console.log(parsedInput);
const body = new FormData();
body.append("Type", parsedInput.type);
body.append("Duration", parsedInput.duration.toString());
const response = await this.client.fetch("/api/vm/gpio", "POST", body);
const data = await response.json();
const parsed = TriggerPowerSchema.parse(data);
return parsed;
}
async triggerReset() {
const result = await this.triggerPower({ duration: 8000 });
return result;
}
}