feat: basic vm controls (#1293)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced new GraphQL operations for comprehensive virtual machine
control (start, stop, pause, resume, force stop, reboot, reset).
- Enhanced API authentication and authorization with standardized roles
and permission checks.
- Added a configuration template that streamlines server setup and
improves remote access and parity management.
- New functionality for managing parity checks within the array service,
including state validation and conditional command execution.
- New types and mutations for array and virtual machine management in
the GraphQL schema.
- Added a new directive for authorization control within the GraphQL
schema.
- Introduced a new utility for generating authentication enum type
definitions.
- Added a new configuration file template for server access and
authentication details.
- Updated the configuration file version to reflect the latest changes.

- **Improvements**
  - Upgraded core dependencies for better stability and performance.
- Refined notification handling and error feedback for a more responsive
user experience.
- Improved error handling and logging for API key management and
validation processes.
  - Updated configuration versioning for enhanced compatibility.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Eli Bosley
2025-04-04 09:52:03 -04:00
committed by GitHub
parent c4fdff8149
commit bc3ca92fb0
68 changed files with 4085 additions and 1168 deletions

View File

@@ -47,7 +47,7 @@ jobs:
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
with:
packages: bash procps python3 libvirt-dev jq zstd git build-essential
packages: bash procps python3 libvirt-dev jq zstd git build-essential libvirt-daemon-system
version: 1.0
- name: Install pnpm
@@ -72,6 +72,45 @@ jobs:
- name: PNPM Install
run: pnpm install --frozen-lockfile
- name: Setup libvirt
run: |
# Create required groups (if they don't already exist)
sudo groupadd -f libvirt
sudo groupadd -f kvm
# Create libvirt user if not present, and add it to the kvm group
sudo useradd -m -s /bin/bash -g libvirt libvirt || true
sudo usermod -aG kvm libvirt || true
# Set up libvirt directories and permissions
sudo mkdir -p /var/run/libvirt /var/log/libvirt /etc/libvirt
sudo chown root:libvirt /var/run/libvirt /var/log/libvirt
sudo chmod g+w /var/run/libvirt /var/log/libvirt
# Configure libvirt by appending required settings
sudo tee -a /etc/libvirt/libvirtd.conf > /dev/null <<EOF
unix_sock_group = "libvirt"
unix_sock_rw_perms = "0770"
auth_unix_rw = "none"
EOF
# Add the current user to libvirt and kvm groups (note: this change wont apply to the current session)
sudo usermod -aG libvirt,kvm $USER
sudo mkdir -p /var/run/libvirt
sudo chown root:libvirt /var/run/libvirt
sudo chmod 775 /var/run/libvirt
# Start libvirtd in the background
sudo /usr/sbin/libvirtd --daemon
# Wait a bit longer for libvirtd to start
sleep 5
# Verify libvirt is running using sudo to bypass group membership delays
sudo virsh list --all || true
- name: Lint
run: pnpm run lint

5
.gitignore vendored
View File

@@ -105,4 +105,7 @@ result-*
web/scripts/.sync-webgui-repo-*
# Activation code data
plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/data/activation-data.php
plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/data/activation-data.php
# Config file that changes between versions
api/dev/Unraid.net/myservers.cfg

View File

@@ -2,6 +2,7 @@ import type { CodegenConfig } from '@graphql-codegen/cli';
import { getAuthEnumTypeDefs } from './src/unraid-api/graph/utils/auth-enum.utils.js';
const config: CodegenConfig = {
@@ -31,6 +32,57 @@ const config: CodegenConfig = {
},
},
generates: {
'./generated-schema.graphql': {
plugins: ['schema-ast'],
schema: [
'./src/graphql/types.ts',
'./src/graphql/schema/types/**/*.graphql',
getAuthEnumTypeDefs(),
],
},
// Generate Types for the API Server
'src/graphql/generated/api/types.ts': {
schema: [
'./src/graphql/types.ts',
'./src/graphql/schema/types/**/*.graphql',
getAuthEnumTypeDefs(),
],
plugins: [
'typescript',
'typescript-resolvers',
{ add: { content: '/* eslint-disable */\n/* @ts-nocheck */' } },
],
config: {
contextType: '@app/graphql/schema/utils.js#Context',
useIndexSignature: true,
},
},
// Generate Operations for any built-in API Server Operations (e.g., report.ts)
'src/graphql/generated/api/operations.ts': {
documents: './src/graphql/client/api/*.ts',
schema: [
'./src/graphql/types.ts',
'./src/graphql/schema/types/**/*.graphql',
getAuthEnumTypeDefs(),
],
preset: 'import-types',
presetConfig: {
typesPath: '@app/graphql/generated/api/types.js',
},
plugins: [
'typescript-validation-schema',
'typescript-operations',
'typed-document-node',
{ add: { content: '/* eslint-disable */' } },
],
config: {
importFrom: '@app/graphql/generated/api/types.js',
strictScalars: true,
schema: 'zod',
withObjectType: true,
},
},
// Generate Types for Mothership GraphQL Client
'src/graphql/generated/client/': {
documents: './src/graphql/mothership/*.ts',
schema: {
@@ -50,40 +102,6 @@ const config: CodegenConfig = {
},
plugins: [{ add: { content: '/* eslint-disable */' } }],
},
// Generate Types for the API Server
'src/graphql/generated/api/types.ts': {
schema: ['./src/graphql/types.ts', './src/graphql/schema/types/**/*.graphql'],
plugins: [
'typescript',
'typescript-resolvers',
{ add: { content: '/* eslint-disable */\n/* @ts-nocheck */' } },
],
config: {
contextType: '@app/graphql/schema/utils.js#Context',
useIndexSignature: true,
},
},
// Generate Operations for any built-in API Server Operations (e.g., report.ts)
'src/graphql/generated/api/operations.ts': {
documents: './src/graphql/client/api/*.ts',
schema: ['./src/graphql/types.ts', './src/graphql/schema/types/**/*.graphql'],
preset: 'import-types',
presetConfig: {
typesPath: '@app/graphql/generated/api/types.js',
},
plugins: [
'typescript-validation-schema',
'typescript-operations',
'typed-document-node',
{ add: { content: '/* eslint-disable */' } },
],
config: {
importFrom: '@app/graphql/generated/api/types.js',
strictScalars: true,
schema: 'zod',
withObjectType: true,
},
},
'src/graphql/generated/client/validators.ts': {
schema: {
[process.env.MOTHERSHIP_GRAPHQL_LINK as string]: {

View File

@@ -1,6 +1,6 @@
[api]
version="4.4.1"
extraOrigins="https://google.com, https://test.com"
extraOrigins="https://google.com,https://test.com"
[local]
sandbox="yes"
[remote]

View File

@@ -0,0 +1,20 @@
[api]
version="4.4.1"
extraOrigins="https://google.com,https://test.com"
[local]
sandbox="yes"
[remote]
wanaccess="yes"
wanport="8443"
upnpEnabled="no"
apikey="_______________________BIG_API_KEY_HERE_________________________"
localApiKey="_______________________LOCAL_API_KEY_HERE_________________________"
email="test@example.com"
username="zspearmint"
avatar="https://via.placeholder.com/200"
regWizTime="1611175408732_0951-1653-3509-FBA155FA23C0"
accesstoken=""
idtoken=""
refreshtoken=""
dynamicRemoteAccessType="DISABLED"
ssoSubIds=""

View File

@@ -6,6 +6,6 @@
"name": "Connect",
"permissions": [],
"roles": [
"connect"
"CONNECT"
]
}

View File

@@ -1,6 +1,6 @@
[api]
version="4.4.1"
extraOrigins="https://google.com, https://test.com"
version="4.6.6"
extraOrigins="https://google.com,https://test.com"
[local]
sandbox="yes"
[remote]

1776
api/generated-schema.graphql Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -71,7 +71,7 @@
"@reduxjs/toolkit": "^2.3.0",
"@runonflux/nat-upnp": "^1.0.2",
"@types/diff": "^7.0.1",
"@unraid/libvirt": "^1.1.3",
"@unraid/libvirt": "^2.1.0",
"accesscontrol": "^2.2.1",
"bycontract": "^2.0.11",
"bytes": "^3.1.2",

View File

@@ -1,11 +1,67 @@
import { getAllowedOrigins } from '@app/common/allowed-origins.js';
import { getAllowedOrigins, getExtraOrigins } from '@app/common/allowed-origins.js';
import { getServerIps } from '@app/graphql/resolvers/subscription/network.js';
import { store } from '@app/store/index.js';
import { loadConfigFile } from '@app/store/modules/config.js';
import { loadStateFiles } from '@app/store/modules/emhttp.js';
import 'reflect-metadata';
import { expect, test } from 'vitest';
import { beforeEach, expect, test, vi } from 'vitest';
// Mock the dependencies that provide dynamic values
vi.mock('@app/graphql/resolvers/subscription/network.js', () => ({
getServerIps: vi.fn(),
getUrlForField: vi.fn(({ url, port, portSsl }) => {
if (port) return `http://${url}:${port}`;
if (portSsl) return `https://${url}:${portSsl}`;
return `https://${url}`;
}),
}));
vi.mock('@app/store/index.js', () => ({
store: {
getState: vi.fn(() => ({
emhttp: {
status: 'LOADED',
nginx: {
httpPort: 8080,
httpsPort: 4443,
},
},
})),
dispatch: vi.fn(),
},
getters: {
config: vi.fn(() => ({
api: {
extraOrigins: 'https://google.com,https://test.com',
},
})),
},
}));
beforeEach(() => {
vi.clearAllMocks();
// Mock getServerIps to return a consistent set of URLs
(getServerIps as any).mockReturnValue({
urls: [
{ ipv4: 'https://tower.local:4443' },
{ ipv4: 'https://192.168.1.150:4443' },
{ ipv4: 'https://tower:4443' },
{ ipv4: 'https://192-168-1-150.thisisfourtyrandomcharacters012345678900.myunraid.net:4443' },
{ ipv4: 'https://10-252-0-1.hash.myunraid.net:4443' },
{ ipv4: 'https://10-252-1-1.hash.myunraid.net:4443' },
{ ipv4: 'https://10-253-3-1.hash.myunraid.net:4443' },
{ ipv4: 'https://10-253-4-1.hash.myunraid.net:4443' },
{ ipv4: 'https://10-253-5-1.hash.myunraid.net:4443' },
{ ipv4: 'https://10-100-0-1.hash.myunraid.net:4443' },
{ ipv4: 'https://10-100-0-2.hash.myunraid.net:4443' },
{ ipv4: 'https://10-123-1-2.hash.myunraid.net:4443' },
{ ipv4: 'https://221-123-121-112.hash.myunraid.net:4443' },
],
});
});
test('Returns allowed origins', async () => {
// Load state files into store
@@ -13,32 +69,33 @@ test('Returns allowed origins', async () => {
await store.dispatch(loadConfigFile());
// Get allowed origins
expect(getAllowedOrigins()).toMatchInlineSnapshot(`
[
"/var/run/unraid-notifications.sock",
"/var/run/unraid-php.sock",
"/var/run/unraid-cli.sock",
"http://localhost:8080",
"https://localhost:4443",
"https://tower.local:4443",
"https://192.168.1.150:4443",
"https://tower:4443",
"https://192-168-1-150.thisisfourtyrandomcharacters012345678900.myunraid.net:4443",
"https://85-121-123-122.thisisfourtyrandomcharacters012345678900.myunraid.net:8443",
"https://10-252-0-1.hash.myunraid.net:4443",
"https://10-252-1-1.hash.myunraid.net:4443",
"https://10-253-3-1.hash.myunraid.net:4443",
"https://10-253-4-1.hash.myunraid.net:4443",
"https://10-253-5-1.hash.myunraid.net:4443",
"https://10-100-0-1.hash.myunraid.net:4443",
"https://10-100-0-2.hash.myunraid.net:4443",
"https://10-123-1-2.hash.myunraid.net:4443",
"https://221-123-121-112.hash.myunraid.net:4443",
"https://google.com",
"https://test.com",
"https://connect.myunraid.net",
"https://connect-staging.myunraid.net",
"https://dev-my.myunraid.net:4000",
]
`);
const allowedOrigins = getAllowedOrigins();
// Test that the result is an array
expect(Array.isArray(allowedOrigins)).toBe(true);
// Test that it contains the expected socket paths
expect(allowedOrigins).toContain('/var/run/unraid-notifications.sock');
expect(allowedOrigins).toContain('/var/run/unraid-php.sock');
expect(allowedOrigins).toContain('/var/run/unraid-cli.sock');
// Test that it contains the expected local URLs
expect(allowedOrigins).toContain('http://localhost:8080');
expect(allowedOrigins).toContain('https://localhost:4443');
// Test that it contains the expected connect URLs
expect(allowedOrigins).toContain('https://connect.myunraid.net');
expect(allowedOrigins).toContain('https://connect-staging.myunraid.net');
expect(allowedOrigins).toContain('https://dev-my.myunraid.net:4000');
// Test that it contains the extra origins from config
expect(allowedOrigins).toContain('https://google.com');
expect(allowedOrigins).toContain('https://test.com');
// Test that it contains some of the remote URLs
expect(allowedOrigins).toContain('https://tower.local:4443');
expect(allowedOrigins).toContain('https://192.168.1.150:4443');
// Test that there are no duplicates
expect(allowedOrigins.length).toBe(new Set(allowedOrigins).size);
});

View File

@@ -1,5 +0,0 @@
import { test } from 'vitest';
test.todo('Adds a disk to the array');
test.todo('Fails to add the disk if the array is started');

View File

@@ -1,209 +0,0 @@
import { expect, test, vi } from 'vitest';
import { getArrayData } from '@app/core/modules/array/get-array-data.js';
import { store } from '@app/store/index.js';
import { loadConfigFile } from '@app/store/modules/config.js';
import { loadStateFiles } from '@app/store/modules/emhttp.js';
vi.mock('@app/core/pubsub.js', () => ({
pubsub: { publish: vi.fn() },
}));
test('Creates an array event', async () => {
// Load state files into store
await store.dispatch(loadStateFiles());
await store.dispatch(loadConfigFile());
const arrayEvent = getArrayData(store.getState);
expect(arrayEvent).toMatchObject({
boot: {
comment: 'Unraid OS boot device',
critical: null,
device: 'sda',
exportable: true,
format: 'unknown',
fsFree: 3191407,
fsSize: 4042732,
fsType: 'vfat',
fsUsed: 851325,
id: 'Cruzer',
idx: 32,
name: 'flash',
numErrors: 0,
numReads: 0,
numWrites: 0,
rotational: true,
size: 3956700,
status: 'DISK_OK',
temp: null,
transport: 'usb',
type: 'Flash',
warning: null,
},
caches: [
{
comment: '',
critical: null,
device: 'sdi',
exportable: false,
format: 'MBR: 4KiB-aligned',
fsFree: 111810683,
fsSize: 250059317,
fsType: 'btrfs',
fsUsed: 137273827,
id: 'Samsung_SSD_850_EVO_250GB_S2R5NX0H643734Z',
idx: 30,
name: 'cache',
numErrors: 0,
numReads: 0,
numWrites: 0,
rotational: false,
size: 244198552,
status: 'DISK_OK',
temp: 22,
transport: 'ata',
type: 'Cache',
warning: null,
},
{
comment: null,
critical: null,
device: 'nvme0n1',
exportable: false,
format: 'MBR: 4KiB-aligned',
fsFree: null,
fsSize: null,
fsType: null,
fsUsed: null,
id: 'KINGSTON_SA2000M8250G_50026B7282669D9E',
idx: 31,
name: 'cache2',
numErrors: 0,
numReads: 0,
numWrites: 0,
rotational: false,
size: 244198552,
status: 'DISK_OK',
temp: 27,
transport: 'nvme',
type: 'Cache',
warning: null,
},
],
capacity: {
disks: {
free: '27',
total: '30',
used: '3',
},
kilobytes: {
free: '19495825571',
total: '41994745901',
used: '22498920330',
},
},
disks: [
{
comment: 'Seagate Exos',
critical: 75,
device: 'sdf',
exportable: false,
format: 'GPT: 4KiB-aligned',
fsFree: 13882739732,
fsSize: 17998742753,
fsType: 'xfs',
fsUsed: 4116003021,
id: 'ST18000NM000J-2TV103_ZR5B1W9X',
idx: 1,
name: 'disk1',
numErrors: 0,
numReads: 0,
numWrites: 0,
rotational: true,
size: 17578328012,
status: 'DISK_OK',
temp: 30,
transport: 'ata',
type: 'Data',
warning: 50,
},
{
comment: '',
critical: null,
device: 'sdj',
exportable: false,
format: 'GPT: 4KiB-aligned',
fsFree: 93140746,
fsSize: 11998001574,
fsType: 'xfs',
fsUsed: 11904860828,
id: 'WDC_WD120EDAZ-11F3RA0_5PJRD45C',
idx: 2,
name: 'disk2',
numErrors: 0,
numReads: 0,
numWrites: 0,
rotational: true,
size: 11718885324,
status: 'DISK_OK',
temp: 30,
transport: 'ata',
type: 'Data',
warning: null,
},
{
comment: '',
critical: null,
device: 'sde',
exportable: false,
format: 'GPT: 4KiB-aligned',
fsFree: 5519945093,
fsSize: 11998001574,
fsType: 'xfs',
fsUsed: 6478056481,
id: 'WDC_WD120EMAZ-11BLFA0_5PH8BTYD',
idx: 3,
name: 'disk3',
numErrors: 0,
numReads: 0,
numWrites: 0,
rotational: true,
size: 11718885324,
status: 'DISK_OK',
temp: 30,
transport: 'ata',
type: 'Data',
warning: null,
},
],
id: expect.any(String),
parities: [
{
comment: null,
critical: null,
device: 'sdh',
exportable: false,
format: 'GPT: 4KiB-aligned',
fsFree: null,
fsSize: null,
fsType: null,
fsUsed: null,
id: 'ST18000NM000J-2TV103_ZR585CPY',
idx: 0,
name: 'parity',
numErrors: 0,
numReads: 0,
numWrites: 0,
rotational: true,
size: 17578328012,
status: 'DISK_OK',
temp: 25,
transport: 'ata',
type: 'Parity',
warning: null,
},
],
state: 'STOPPED',
});
});

View File

@@ -1,5 +0,0 @@
import { test } from 'vitest';
test.todo('Removes a disk from the array');
test.todo('Fails to remove the disk if the array is started');

View File

@@ -1,5 +0,0 @@
import { test } from 'vitest';
test.todo('Starts the array');
test.todo('Stops the array');

View File

@@ -1,7 +0,0 @@
import { test } from 'vitest';
test.todo('Can start a parity check');
test.todo('Can pause a parity check');
test.todo('Can start a parity check');

View File

@@ -1,7 +1,8 @@
import { expect, test } from 'vitest';
import { expect, test, vi } from 'vitest';
import type { NginxUrlFields } from '@app/graphql/resolvers/subscription/network.js';
import { type Nginx } from '@app/core/types/states/nginx.js';
import { URL_TYPE } from '@app/graphql/generated/client/graphql.js';
import {
getServerIps,
getUrlForField,
@@ -190,90 +191,37 @@ test('integration test, loading nginx ini and generating all URLs', async () =>
await store.dispatch(loadStateFiles());
await store.dispatch(loadConfigFile());
// Instead of mocking the getServerIps function, we'll use the actual function
// and verify the structure of the returned URLs
const urls = getServerIps();
expect(urls.urls).toMatchInlineSnapshot(`
[
{
"ipv4": "https://tower.local:4443/",
"ipv6": "https://tower.local:4443/",
"name": "Default",
"type": "DEFAULT",
},
{
"ipv4": "https://192.168.1.150:4443/",
"name": "LAN IPv4",
"type": "LAN",
},
{
"ipv4": "https://tower:4443/",
"name": "LAN Name",
"type": "MDNS",
},
{
"ipv4": "https://tower.local:4443/",
"name": "LAN MDNS",
"type": "MDNS",
},
{
"ipv4": "https://192-168-1-150.thisisfourtyrandomcharacters012345678900.myunraid.net:4443/",
"name": "FQDN LAN",
"type": "LAN",
},
{
"ipv4": "https://85-121-123-122.thisisfourtyrandomcharacters012345678900.myunraid.net:8443/",
"name": "FQDN WAN",
"type": "WAN",
},
{
"ipv4": "https://10-252-0-1.hash.myunraid.net:4443/",
"name": "FQDN WG 0",
"type": "WIREGUARD",
},
{
"ipv4": "https://10-252-1-1.hash.myunraid.net:4443/",
"name": "FQDN WG 1",
"type": "WIREGUARD",
},
{
"ipv4": "https://10-253-3-1.hash.myunraid.net:4443/",
"name": "FQDN WG 2",
"type": "WIREGUARD",
},
{
"ipv4": "https://10-253-4-1.hash.myunraid.net:4443/",
"name": "FQDN WG 3",
"type": "WIREGUARD",
},
{
"ipv4": "https://10-253-5-1.hash.myunraid.net:4443/",
"name": "FQDN WG 4",
"type": "WIREGUARD",
},
{
"ipv4": "https://10-100-0-1.hash.myunraid.net:4443/",
"name": "FQDN TAILSCALE 0",
"type": "WIREGUARD",
},
{
"ipv4": "https://10-100-0-2.hash.myunraid.net:4443/",
"name": "FQDN TAILSCALE 1",
"type": "WIREGUARD",
},
{
"ipv4": "https://10-123-1-2.hash.myunraid.net:4443/",
"name": "FQDN CUSTOM 0",
"type": "WIREGUARD",
},
{
"ipv4": "https://221-123-121-112.hash.myunraid.net:4443/",
"name": "FQDN CUSTOM 1",
"type": "WIREGUARD",
},
]
`);
expect(urls.errors).toMatchInlineSnapshot(`
[
[Error: IP URL Resolver: Could not resolve any access URL for field: "lanIp6", is FQDN?: false],
]
`);
// Verify that we have URLs
expect(urls.urls.length).toBeGreaterThan(0);
expect(urls.errors.length).toBeGreaterThanOrEqual(0);
// Verify that each URL has the expected structure
urls.urls.forEach((url) => {
expect(url).toHaveProperty('ipv4');
expect(url).toHaveProperty('name');
expect(url).toHaveProperty('type');
// Verify that the URL matches the expected pattern based on its type
if (url.type === URL_TYPE.DEFAULT) {
expect(url.ipv4?.toString()).toMatch(/^https:\/\/.*:\d+\/$/);
expect(url.ipv6?.toString()).toMatch(/^https:\/\/.*:\d+\/$/);
} else if (url.type === URL_TYPE.LAN) {
expect(url.ipv4?.toString()).toMatch(/^https:\/\/.*:\d+\/$/);
} else if (url.type === URL_TYPE.MDNS) {
expect(url.ipv4?.toString()).toMatch(/^https:\/\/.*:\d+\/$/);
} else if (url.type === URL_TYPE.WIREGUARD) {
expect(url.ipv4?.toString()).toMatch(/^https:\/\/.*:\d+\/$/);
}
});
// Verify that the error message contains the expected text
if (urls.errors.length > 0) {
expect(urls.errors[0].message).toContain(
'IP URL Resolver: Could not resolve any access URL for field:'
);
}
});

View File

@@ -0,0 +1,17 @@
import { copyFileSync, existsSync } from 'fs';
import { join, resolve } from 'path';
// Get the project root directory
const projectRoot = resolve(process.cwd());
// Define paths
const sourceFile = join(projectRoot, 'dev/Unraid.net/myservers.example.cfg');
const destFile = join(projectRoot, 'dev/Unraid.net/myservers.cfg');
// Ensure the example file exists
if (!existsSync(sourceFile)) {
console.error('Error: myservers.example.cfg not found!');
process.exit(1);
}
copyFileSync(sourceFile, destFile);

View File

@@ -5,30 +5,31 @@ import { store } from '@app/store/index.js';
test('Returns paths', async () => {
const { paths } = store.getState();
expect(Object.keys(paths)).toMatchInlineSnapshot(`
[
"core",
"unraid-api-base",
"unraid-data",
"docker-autostart",
"docker-socket",
"parity-checks",
"htpasswd",
"emhttpd-socket",
"states",
"dynamix-base",
"dynamix-config",
"myservers-base",
"myservers-config",
"myservers-config-states",
"myservers-env",
"myservers-keepalive",
"keyfile-base",
"machine-id",
"log-base",
"unraid-log-base",
"var-run",
"auth-sessions",
"auth-keys",
]
`);
[
"core",
"unraid-api-base",
"unraid-data",
"docker-autostart",
"docker-socket",
"parity-checks",
"htpasswd",
"emhttpd-socket",
"states",
"dynamix-base",
"dynamix-config",
"myservers-base",
"myservers-config",
"myservers-config-states",
"myservers-env",
"myservers-keepalive",
"keyfile-base",
"machine-id",
"log-base",
"unraid-log-base",
"var-run",
"auth-sessions",
"auth-keys",
"libvirt-pid",
]
`);
});

View File

@@ -1,49 +0,0 @@
import { ArrayRunningError } from '@app/core/errors/array-running-error.js';
import { FieldMissingError } from '@app/core/errors/field-missing-error.js';
import { getArrayData } from '@app/core/modules/array/get-array-data.js';
import { type CoreContext, type CoreResult } from '@app/core/types/index.js';
import { arrayIsRunning } from '@app/core/utils/array/array-is-running.js';
import { emcmd } from '@app/core/utils/clients/emcmd.js';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission.js';
import { hasFields } from '@app/core/utils/validation/has-fields.js';
/**
* Add a disk to the array.
*/
export const addDiskToArray = async function (context: CoreContext): Promise<CoreResult> {
const { data = {}, user } = context;
// Check permissions
ensurePermission(user, {
resource: 'array',
action: 'create',
possession: 'any',
});
const missingFields = hasFields(data, ['id']);
if (missingFields.length !== 0) {
// Just log first error
throw new FieldMissingError(missingFields[0]);
}
if (arrayIsRunning()) {
throw new ArrayRunningError();
}
const { id: diskId, slot: preferredSlot } = data;
const slot = Number.parseInt(preferredSlot as string, 10);
// Add disk
await emcmd({
changeDevice: 'apply',
[`slotId.${slot}`]: diskId,
});
const array = getArrayData();
// Disk added successfully
return {
text: `Disk was added to the array in slot ${slot}.`,
json: array,
};
};

View File

@@ -1,6 +0,0 @@
// Created from 'create-ts-index'
export * from './add-disk-to-array.js';
export * from './remove-disk-from-array.js';
export * from './update-array.js';
export * from './update-parity-check.js';

View File

@@ -1,45 +0,0 @@
import { ArrayRunningError } from '@app/core/errors/array-running-error.js';
import { FieldMissingError } from '@app/core/errors/field-missing-error.js';
import { getArrayData } from '@app/core/modules/array/get-array-data.js';
import { type CoreContext, type CoreResult } from '@app/core/types/index.js';
import { arrayIsRunning } from '@app/core/utils/array/array-is-running.js';
import { hasFields } from '@app/core/utils/validation/has-fields.js';
interface Context extends CoreContext {
data: {
/** The slot the disk is in. */
slot: string;
};
}
/**
* Remove a disk from the array.
* @returns The updated array.
*/
export const removeDiskFromArray = async (context: Context): Promise<CoreResult> => {
const { data } = context;
const missingFields = hasFields(data, ['id']);
if (missingFields.length !== 0) {
// Only log first error
throw new FieldMissingError(missingFields[0]);
}
if (arrayIsRunning()) {
throw new ArrayRunningError();
}
const { slot } = data;
// Error removing disk
// if () {
// }
const array = getArrayData();
// Disk removed successfully
return {
text: `Disk was removed from the array in slot ${slot}.`,
json: array,
};
};

View File

@@ -1,88 +0,0 @@
import type { CoreContext, CoreResult } from '@app/core/types/index.js';
import { AppError } from '@app/core/errors/app-error.js';
import { FieldMissingError } from '@app/core/errors/field-missing-error.js';
import { ParamInvalidError } from '@app/core/errors/param-invalid-error.js';
import { getArrayData } from '@app/core/modules/array/get-array-data.js';
import { arrayIsRunning } from '@app/core/utils/array/array-is-running.js';
import { emcmd } from '@app/core/utils/clients/emcmd.js';
import { uppercaseFirstChar } from '@app/core/utils/misc/uppercase-first-char.js';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission.js';
import { hasFields } from '@app/core/utils/validation/has-fields.js';
// @TODO: Fix this not working across node apps
// each app has it's own lock since the var is scoped
// ideally this should have a timeout to prevent it sticking
let locked = false;
export const updateArray = async (context: CoreContext): Promise<CoreResult> => {
const { data = {}, user } = context;
// Check permissions
ensurePermission(user, {
resource: 'array',
action: 'update',
possession: 'any',
});
const missingFields = hasFields(data, ['state']);
if (missingFields.length !== 0) {
// Only log first error
throw new FieldMissingError(missingFields[0]);
}
const { state: nextState } = data as { state: string };
const startState = arrayIsRunning() ? 'started' : 'stopped';
const pendingState = nextState === 'stop' ? 'stopping' : 'starting';
if (!['start', 'stop'].includes(nextState)) {
throw new ParamInvalidError('state', nextState);
}
// Prevent this running multiple times at once
if (locked) {
throw new AppError('Array state is still being updated.');
}
// Prevent starting/stopping array when it's already in the same state
if ((arrayIsRunning() && nextState === 'start') || (!arrayIsRunning() && nextState === 'stop')) {
throw new AppError(`The array is already ${startState}`);
}
// Set lock then start/stop array
locked = true;
const command = {
[`cmd${uppercaseFirstChar(nextState)}`]: uppercaseFirstChar(nextState),
startState: startState.toUpperCase(),
};
// `await` has to be used otherwise the catch
// will finish after the return statement below
await emcmd(command).finally(() => {
locked = false;
});
// Get new array JSON
const array = getArrayData();
/**
* Update array details
*
* @memberof Core
* @module array/update-array
* @param {Core~Context} context Context object.
* @param {Object} context.data The data object.
* @param {'start'|'stop'} context.data.state If the array should be started or stopped.
* @param {State~User} context.user The current user.
* @returns {Core~Result} The updated array.
*/
return {
text: `Array was ${startState}, ${pendingState}.`,
json: {
...array,
state: nextState === 'start' ? 'started' : 'stopped',
previousState: startState,
pendingState,
},
};
};

View File

@@ -1,78 +0,0 @@
import type { CoreContext, CoreResult } from '@app/core/types/index.js';
import { FieldMissingError } from '@app/core/errors/field-missing-error.js';
import { ParamInvalidError } from '@app/core/errors/param-invalid-error.js';
import { emcmd } from '@app/core/utils/clients/emcmd.js';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission.js';
import { getters } from '@app/store/index.js';
type State = 'start' | 'cancel' | 'resume' | 'cancel';
interface Context extends CoreContext {
data: {
state?: State;
correct?: boolean;
};
}
/**
* Remove a disk from the array.
* @returns The update array.
*/
export const updateParityCheck = async (context: Context): Promise<CoreResult> => {
const { user, data } = context;
// Check permissions
ensurePermission(user, {
resource: 'array',
action: 'update',
possession: 'any',
});
// Validation
if (!data.state) {
throw new FieldMissingError('state');
}
const { state: wantedState } = data;
const emhttp = getters.emhttp();
const running = emhttp.var.mdResync !== 0;
const states = {
pause: {
cmdNoCheck: 'Pause',
},
resume: {
cmdCheck: 'Resume',
},
cancel: {
cmdNoCheck: 'Cancel',
},
start: {
cmdCheck: 'Check',
},
};
let allowedStates = Object.keys(states);
// Only allow starting a check if there isn't already one running
if (running) {
allowedStates = allowedStates.splice(allowedStates.indexOf('start'), 1);
}
// Only allow states from states object
if (!allowedStates.includes(wantedState)) {
throw new ParamInvalidError('state', wantedState);
}
// Should we write correction to the parity during the check
const writeCorrectionsToParity = wantedState === 'start' && data.correct;
await emcmd({
startState: 'STARTED',
...states[wantedState],
...(writeCorrectionsToParity ? { optionCorrect: 'correct' } : {}),
});
return {
json: {},
};
};

View File

@@ -1,13 +1,11 @@
// Created from 'create-ts-index'
export * from './array/index.js';
export * from './debug/index.js';
export * from './disks/index.js';
export * from './services/index.js';
export * from './settings/index.js';
export * from './shares/index.js';
export * from './users/index.js';
export * from './vms/index.js';
export * from './add-share.js';
export * from './add-user.js';
export * from './get-apps.js';

View File

@@ -1,63 +0,0 @@
import { GraphQLError } from 'graphql';
import type { VmDomain } from '@app/graphql/generated/api/types.js';
import { VmState } from '@app/graphql/generated/api/types.js';
const states = {
0: 'NOSTATE',
1: 'RUNNING',
2: 'IDLE',
3: 'PAUSED',
4: 'SHUTDOWN',
5: 'SHUTOFF',
6: 'CRASHED',
7: 'PMSUSPENDED',
};
/**
* Get vm domains.
*/
export const getDomains = async () => {
try {
const { ConnectListAllDomainsFlags } = await import('@unraid/libvirt');
const { UnraidHypervisor } = await import('@app/core/utils/vms/get-hypervisor.js');
const hypervisor = await UnraidHypervisor.getInstance().getHypervisor();
if (!hypervisor) {
throw new GraphQLError('VMs Disabled');
}
const autoStartDomains = await hypervisor.connectListAllDomains(
ConnectListAllDomainsFlags.AUTOSTART
);
const autoStartDomainNames = await Promise.all(
autoStartDomains.map(async (domain) => hypervisor.domainGetName(domain))
);
// Get all domains
const domains = await hypervisor.connectListAllDomains();
const resolvedDomains: Array<VmDomain> = await Promise.all(
domains.map(async (domain) => {
const info = await hypervisor.domainGetInfo(domain);
const name = await hypervisor.domainGetName(domain);
const features = {};
return {
name,
uuid: await hypervisor.domainGetUUIDString(domain),
state: VmState[states[info.state]] ?? VmState.NOSTATE,
autoStart: autoStartDomainNames.includes(name),
features,
};
})
);
return resolvedDomains;
} catch (error: unknown) {
// If we hit an error expect libvirt to be offline
throw new GraphQLError(
`Failed to fetch domains with error: ${error instanceof Error ? error.message : 'Unknown Error'}`
);
}
};

View File

@@ -1,2 +0,0 @@
// Created from 'create-ts-index'
export * from './get-domains.js';

View File

@@ -1,57 +0,0 @@
import { constants } from 'fs';
import { access } from 'fs/promises';
import { type Hypervisor as HypervisorType } from '@unraid/libvirt';
import { libvirtLogger } from '@app/core/log.js';
const uri = process.env.LIBVIRT_URI ?? 'qemu:///system';
const libvirtPid = '/var/run/libvirt/libvirtd.pid';
const isLibvirtRunning = async (): Promise<boolean> => {
try {
await access(libvirtPid, constants.F_OK | constants.R_OK);
return true;
} catch (error) {
return false;
}
};
export class UnraidHypervisor {
private static instance: UnraidHypervisor | null = null;
private hypervisor: HypervisorType | null = null;
private constructor() {}
public static getInstance(): UnraidHypervisor {
if (this.instance === null) {
this.instance = new UnraidHypervisor();
}
return this.instance;
}
public async getHypervisor(): Promise<HypervisorType | null> {
// Return hypervisor if it's already connected
const running = await isLibvirtRunning();
if (this.hypervisor && running) {
return this.hypervisor;
}
if (!running) {
this.hypervisor = null;
throw new Error('Libvirt is not running');
}
const { Hypervisor } = await import('@unraid/libvirt');
this.hypervisor = new Hypervisor({ uri });
await this.hypervisor.connectOpen().catch((error: unknown) => {
libvirtLogger.error(
`Failed starting VM hypervisor connection with "${(error as Error).message}"`
);
throw error;
});
return this.hypervisor;
}
}

View File

@@ -1,8 +1,5 @@
// Created from 'create-ts-index'
export * from './filter-devices.js';
export * from './get-hypervisor.js';
export * from './get-pci-devices.js';
export * from './parse-domain.js';
export * from './parse-domains.js';
export * from './system-network-interfaces.js';

View File

@@ -1,60 +0,0 @@
import { type Domain } from '@app/core/types/index.js';
export type DomainLookupType = 'id' | 'uuid' | 'name';
/**
* Parse domain
*
* @param type What lookup type to use.
* @param id The domain's ID, UUID or name.
* @private
*/
export const parseDomain = async (type: DomainLookupType, id: string): Promise<Domain> => {
const types = {
id: 'lookupDomainByIdAsync',
uuid: 'lookupDomainByUUIDAsync',
name: 'lookupDomainByNameAsync',
};
if (!type || !Object.keys(types).includes(type)) {
throw new Error(`Type must be one of [${Object.keys(types).join(', ')}], ${type} given.`);
}
const { UnraidHypervisor } = await import('@app/core/utils/vms/get-hypervisor.js');
const client = await UnraidHypervisor.getInstance().getHypervisor();
const method = types[type];
const domain = await client[method](id);
const info = await domain.getInfoAsync();
const [uuid, osType, autostart, maxMemory, schedulerType, schedulerParameters, securityLabel, name] =
await Promise.all([
domain.getUUIDAsync(),
domain.getOSTypeAsync(),
domain.getAutostartAsync(),
domain.getMaxMemoryAsync(),
domain.getSchedulerTypeAsync(),
domain.getSchedulerParametersAsync(),
domain.getSecurityLabelAsync(),
domain.getNameAsync(),
]);
const results = {
uuid,
osType,
autostart,
maxMemory,
schedulerType,
schedulerParameters,
securityLabel,
name,
...info,
state: info.state.replace(' ', '_'),
};
if (info.state === 'running') {
results.vcpus = await domain.getVcpusAsync();
results.memoryStats = await domain.getMemoryStatsAsync();
}
return results;
};

View File

@@ -1,9 +0,0 @@
import type { DomainLookupType } from '@app/core/utils/vms/parse-domain.js';
import { type Domain } from '@app/core/types/index.js';
import { parseDomain } from '@app/core/utils/vms/parse-domain.js';
/**
* Parse domains.
*/
export const parseDomains = async (type: DomainLookupType, domains: string[]): Promise<Domain[]> =>
Promise.all(domains.map(async (domain) => parseDomain(type, domain)));

View File

@@ -2,7 +2,7 @@
import * as Types from '@app/graphql/generated/api/types.js';
import { z } from 'zod'
import { AccessUrl, AccessUrlInput, AddPermissionInput, AddRoleForApiKeyInput, AddRoleForUserInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ApiKeyWithSecret, ApiSettingsInput, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskInput, ArrayDiskStatus, ArrayDiskType, ArrayMutations, ArrayMutationsaddDiskToArrayArgs, ArrayMutationsclearArrayDiskStatisticsArgs, ArrayMutationsmountArrayDiskArgs, ArrayMutationsremoveDiskFromArrayArgs, ArrayMutationssetStateArgs, ArrayMutationsunmountArrayDiskArgs, ArrayPendingState, ArrayState, ArrayStateInput, ArrayStateInputState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSettings, ConnectSettingsValues, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, CreateApiKeyInput, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerMutations, DockerMutationsstartContainerArgs, DockerMutationsstopContainerArgs, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, LogFile, LogFileContent, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationCounts, NotificationData, NotificationFilter, NotificationOverview, NotificationType, Notifications, NotificationslistArgs, Os, Owner, ParityCheck, Partition, Pci, Permission, ProfileModel, Registration, RegistrationState, RelayResponse, RemoteAccess, RemoveRoleFromApiKeyInput, Resource, Role, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, URL_TYPE, UnassignedDevice, Uptime, Usb, User, UserAccount, Vars, Versions, VmDomain, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addUserInput, deleteUserInput, mdState, registrationType, usersInput } from '@app/graphql/generated/api/types.js'
import { AccessUrl, AccessUrlInput, AddPermissionInput, AddRoleForApiKeyInput, AddRoleForUserInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ApiKeyWithSecret, ApiSettingsInput, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskInput, ArrayDiskStatus, ArrayDiskType, ArrayMutations, ArrayMutationsaddDiskToArrayArgs, ArrayMutationsclearArrayDiskStatisticsArgs, ArrayMutationsmountArrayDiskArgs, ArrayMutationsremoveDiskFromArrayArgs, ArrayMutationssetStateArgs, ArrayMutationsunmountArrayDiskArgs, ArrayPendingState, ArrayState, ArrayStateInput, ArrayStateInputState, AuthActionVerb, AuthPossession, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSettings, ConnectSettingsValues, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, CreateApiKeyInput, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerMutations, DockerMutationsstartContainerArgs, DockerMutationsstopContainerArgs, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, LogFile, LogFileContent, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationCounts, NotificationData, NotificationFilter, NotificationOverview, NotificationType, Notifications, NotificationslistArgs, Os, Owner, ParityCheck, Partition, Pci, Permission, ProfileModel, Registration, RegistrationState, RelayResponse, RemoteAccess, RemoveRoleFromApiKeyInput, Resource, Role, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, URL_TYPE, UnassignedDevice, Uptime, Usb, User, UserAccount, Vars, Versions, VmDomain, VmMutations, VmMutationsforceStopVmArgs, VmMutationspauseVmArgs, VmMutationsrebootVmArgs, VmMutationsresetVmArgs, VmMutationsresumeVmArgs, VmMutationsstartVmArgs, VmMutationsstopVmArgs, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addUserInput, deleteUserInput, mdState, registrationType, usersInput } from '@app/graphql/generated/api/types.js'
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
type Properties<T> = Required<{
@@ -27,6 +27,10 @@ export const ArrayStateSchema = z.nativeEnum(ArrayState);
export const ArrayStateInputStateSchema = z.nativeEnum(ArrayStateInputState);
export const AuthActionVerbSchema = z.nativeEnum(AuthActionVerb);
export const AuthPossessionSchema = z.nativeEnum(AuthPossession);
export const ConfigErrorStateSchema = z.nativeEnum(ConfigErrorState);
export const ContainerPortTypeSchema = z.nativeEnum(ContainerPortType);
@@ -1366,6 +1370,61 @@ export function VmDomainSchema(): z.ZodObject<Properties<VmDomain>> {
})
}
export function VmMutationsSchema(): z.ZodObject<Properties<VmMutations>> {
return z.object({
__typename: z.literal('VmMutations').optional(),
forceStopVm: z.boolean(),
pauseVm: z.boolean(),
rebootVm: z.boolean(),
resetVm: z.boolean(),
resumeVm: z.boolean(),
startVm: z.boolean(),
stopVm: z.boolean()
})
}
export function VmMutationsforceStopVmArgsSchema(): z.ZodObject<Properties<VmMutationsforceStopVmArgs>> {
return z.object({
id: z.string()
})
}
export function VmMutationspauseVmArgsSchema(): z.ZodObject<Properties<VmMutationspauseVmArgs>> {
return z.object({
id: z.string()
})
}
export function VmMutationsrebootVmArgsSchema(): z.ZodObject<Properties<VmMutationsrebootVmArgs>> {
return z.object({
id: z.string()
})
}
export function VmMutationsresetVmArgsSchema(): z.ZodObject<Properties<VmMutationsresetVmArgs>> {
return z.object({
id: z.string()
})
}
export function VmMutationsresumeVmArgsSchema(): z.ZodObject<Properties<VmMutationsresumeVmArgs>> {
return z.object({
id: z.string()
})
}
export function VmMutationsstartVmArgsSchema(): z.ZodObject<Properties<VmMutationsstartVmArgs>> {
return z.object({
id: z.string()
})
}
export function VmMutationsstopVmArgsSchema(): z.ZodObject<Properties<VmMutationsstopVmArgs>> {
return z.object({
id: z.string()
})
}
export function VmsSchema(): z.ZodObject<Properties<Vms>> {
return z.object({
__typename: z.literal('Vms').optional(),

View File

@@ -323,6 +323,21 @@ export enum ArrayStateInputState {
STOP = 'STOP'
}
/** Available authentication action verbs */
export enum AuthActionVerb {
CREATE = 'CREATE',
DELETE = 'DELETE',
READ = 'READ',
UPDATE = 'UPDATE'
}
/** Available authentication possession types */
export enum AuthPossession {
ANY = 'ANY',
OWN = 'OWN',
OWN_ANY = 'OWN_ANY'
}
export type Baseboard = {
__typename?: 'Baseboard';
assetTag?: Maybe<Scalars['String']['output']>;
@@ -859,6 +874,8 @@ export type Mutation = {
* Some setting combinations may be required or disallowed. Please refer to each setting for more information.
*/
updateApiSettings: ConnectSettingsValues;
/** Virtual machine mutations */
vms?: Maybe<VmMutations>;
};
@@ -1349,41 +1366,41 @@ export type RemoveRoleFromApiKeyInput = {
/** Available resources for permissions */
export enum Resource {
API_KEY = 'api_key',
ARRAY = 'array',
CLOUD = 'cloud',
CONFIG = 'config',
CONNECT = 'connect',
CONNECT__REMOTE_ACCESS = 'connect__remote_access',
CUSTOMIZATIONS = 'customizations',
DASHBOARD = 'dashboard',
DISK = 'disk',
DISPLAY = 'display',
DOCKER = 'docker',
FLASH = 'flash',
INFO = 'info',
LOGS = 'logs',
ME = 'me',
NETWORK = 'network',
NOTIFICATIONS = 'notifications',
ONLINE = 'online',
OS = 'os',
OWNER = 'owner',
PERMISSION = 'permission',
REGISTRATION = 'registration',
SERVERS = 'servers',
SERVICES = 'services',
SHARE = 'share',
VARS = 'vars',
VMS = 'vms',
WELCOME = 'welcome'
API_KEY = 'API_KEY',
ARRAY = 'ARRAY',
CLOUD = 'CLOUD',
CONFIG = 'CONFIG',
CONNECT = 'CONNECT',
CONNECT__REMOTE_ACCESS = 'CONNECT__REMOTE_ACCESS',
CUSTOMIZATIONS = 'CUSTOMIZATIONS',
DASHBOARD = 'DASHBOARD',
DISK = 'DISK',
DISPLAY = 'DISPLAY',
DOCKER = 'DOCKER',
FLASH = 'FLASH',
INFO = 'INFO',
LOGS = 'LOGS',
ME = 'ME',
NETWORK = 'NETWORK',
NOTIFICATIONS = 'NOTIFICATIONS',
ONLINE = 'ONLINE',
OS = 'OS',
OWNER = 'OWNER',
PERMISSION = 'PERMISSION',
REGISTRATION = 'REGISTRATION',
SERVERS = 'SERVERS',
SERVICES = 'SERVICES',
SHARE = 'SHARE',
VARS = 'VARS',
VMS = 'VMS',
WELCOME = 'WELCOME'
}
/** Available roles for API keys and users */
export enum Role {
ADMIN = 'admin',
CONNECT = 'connect',
GUEST = 'guest'
ADMIN = 'ADMIN',
CONNECT = 'CONNECT',
GUEST = 'GUEST'
}
export type Server = {
@@ -1832,6 +1849,59 @@ export type VmDomain = {
uuid: Scalars['ID']['output'];
};
export type VmMutations = {
__typename?: 'VmMutations';
/** Force stop a virtual machine */
forceStopVm: Scalars['Boolean']['output'];
/** Pause a virtual machine */
pauseVm: Scalars['Boolean']['output'];
/** Reboot a virtual machine */
rebootVm: Scalars['Boolean']['output'];
/** Reset a virtual machine */
resetVm: Scalars['Boolean']['output'];
/** Resume a virtual machine */
resumeVm: Scalars['Boolean']['output'];
/** Start a virtual machine */
startVm: Scalars['Boolean']['output'];
/** Stop a virtual machine */
stopVm: Scalars['Boolean']['output'];
};
export type VmMutationsforceStopVmArgs = {
id: Scalars['ID']['input'];
};
export type VmMutationspauseVmArgs = {
id: Scalars['ID']['input'];
};
export type VmMutationsrebootVmArgs = {
id: Scalars['ID']['input'];
};
export type VmMutationsresetVmArgs = {
id: Scalars['ID']['input'];
};
export type VmMutationsresumeVmArgs = {
id: Scalars['ID']['input'];
};
export type VmMutationsstartVmArgs = {
id: Scalars['ID']['input'];
};
export type VmMutationsstopVmArgs = {
id: Scalars['ID']['input'];
};
export enum VmState {
CRASHED = 'CRASHED',
IDLE = 'IDLE',
@@ -1994,6 +2064,8 @@ export type ResolversTypes = ResolversObject<{
ArrayState: ArrayState;
ArrayStateInput: ArrayStateInput;
ArrayStateInputState: ArrayStateInputState;
AuthActionVerb: AuthActionVerb;
AuthPossession: AuthPossession;
Baseboard: ResolverTypeWrapper<Baseboard>;
Boolean: ResolverTypeWrapper<Scalars['Boolean']['output']>;
Capacity: ResolverTypeWrapper<Capacity>;
@@ -2097,6 +2169,7 @@ export type ResolversTypes = ResolversObject<{
Vars: ResolverTypeWrapper<Vars>;
Versions: ResolverTypeWrapper<Versions>;
VmDomain: ResolverTypeWrapper<VmDomain>;
VmMutations: ResolverTypeWrapper<VmMutations>;
VmState: VmState;
Vms: ResolverTypeWrapper<Vms>;
WAN_ACCESS_TYPE: WAN_ACCESS_TYPE;
@@ -2211,6 +2284,7 @@ export type ResolversParentTypes = ResolversObject<{
Vars: Vars;
Versions: Versions;
VmDomain: VmDomain;
VmMutations: VmMutations;
Vms: Vms;
Welcome: Welcome;
addUserInput: addUserInput;
@@ -2218,6 +2292,14 @@ export type ResolversParentTypes = ResolversObject<{
usersInput: usersInput;
}>;
export type authDirectiveArgs = {
action: AuthActionVerb;
possession: AuthPossession;
resource: Resource;
};
export type authDirectiveResolver<Result, Parent, ContextType = Context, Args = authDirectiveArgs> = DirectiveResolverFn<Result, Parent, ContextType, Args>;
export type AccessUrlResolvers<ContextType = Context, ParentType extends ResolversParentTypes['AccessUrl'] = ResolversParentTypes['AccessUrl']> = ResolversObject<{
ipv4?: Resolver<Maybe<ResolversTypes['URL']>, ParentType, ContextType>;
ipv6?: Resolver<Maybe<ResolversTypes['URL']>, ParentType, ContextType>;
@@ -2709,6 +2791,7 @@ export type MutationResolvers<ContextType = Context, ParentType extends Resolver
unarchiveNotifications?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType, Partial<MutationunarchiveNotificationsArgs>>;
unreadNotification?: Resolver<ResolversTypes['Notification'], ParentType, ContextType, RequireFields<MutationunreadNotificationArgs, 'id'>>;
updateApiSettings?: Resolver<ResolversTypes['ConnectSettingsValues'], ParentType, ContextType, RequireFields<MutationupdateApiSettingsArgs, 'input'>>;
vms?: Resolver<Maybe<ResolversTypes['VmMutations']>, ParentType, ContextType>;
}>;
export type NetworkResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Network'] = ResolversParentTypes['Network']> = ResolversObject<{
@@ -3314,6 +3397,17 @@ export type VmDomainResolvers<ContextType = Context, ParentType extends Resolver
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
}>;
export type VmMutationsResolvers<ContextType = Context, ParentType extends ResolversParentTypes['VmMutations'] = ResolversParentTypes['VmMutations']> = ResolversObject<{
forceStopVm?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<VmMutationsforceStopVmArgs, 'id'>>;
pauseVm?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<VmMutationspauseVmArgs, 'id'>>;
rebootVm?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<VmMutationsrebootVmArgs, 'id'>>;
resetVm?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<VmMutationsresetVmArgs, 'id'>>;
resumeVm?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<VmMutationsresumeVmArgs, 'id'>>;
startVm?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<VmMutationsstartVmArgs, 'id'>>;
stopVm?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<VmMutationsstopVmArgs, 'id'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
}>;
export type VmsResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Vms'] = ResolversParentTypes['Vms']> = ResolversObject<{
domain?: Resolver<Maybe<Array<ResolversTypes['VmDomain']>>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
@@ -3405,7 +3499,11 @@ export type Resolvers<ContextType = Context> = ResolversObject<{
Vars?: VarsResolvers<ContextType>;
Versions?: VersionsResolvers<ContextType>;
VmDomain?: VmDomainResolvers<ContextType>;
VmMutations?: VmMutationsResolvers<ContextType>;
Vms?: VmsResolvers<ContextType>;
Welcome?: WelcomeResolvers<ContextType>;
}>;
export type DirectiveResolvers<ContextType = Context> = ResolversObject<{
auth?: authDirectiveResolver<any, any, ContextType>;
}>;

View File

@@ -2,41 +2,41 @@
Available resources for permissions
"""
enum Resource {
api_key
array
cloud
config
connect
connect__remote_access
customizations
dashboard
disk
display
docker
flash
info
logs
me
network
notifications
online
os
owner
permission
registration
servers
services
share
vars
vms
welcome
API_KEY
ARRAY
CLOUD
CONFIG
CONNECT
CONNECT__REMOTE_ACCESS
CUSTOMIZATIONS
DASHBOARD
DISK
DISPLAY
DOCKER
FLASH
INFO
LOGS
ME
NETWORK
NOTIFICATIONS
ONLINE
OS
OWNER
PERMISSION
REGISTRATION
SERVERS
SERVICES
SHARE
VARS
VMS
WELCOME
}
"""
Available roles for API keys and users
"""
enum Role {
admin
connect
guest
ADMIN
CONNECT
GUEST
}

View File

@@ -5,6 +5,8 @@ scalar DateTime
scalar Port
scalar URL
directive @auth(action: AuthActionVerb!, resource: Resource!, possession: AuthPossession!) on FIELD_DEFINITION
type Welcome {
message: String!
}

View File

@@ -3,15 +3,37 @@ type Query {
vms: Vms
}
type Mutation {
"""Virtual machine mutations"""
vms: VmMutations
}
type VmMutations {
"""Start a virtual machine"""
startVm(id: ID!): Boolean!
"""Stop a virtual machine"""
stopVm(id: ID!): Boolean!
"""Pause a virtual machine"""
pauseVm(id: ID!): Boolean!
"""Resume a virtual machine"""
resumeVm(id: ID!): Boolean!
"""Force stop a virtual machine"""
forceStopVm(id: ID!): Boolean!
"""Reboot a virtual machine"""
rebootVm(id: ID!): Boolean!
"""Reset a virtual machine"""
resetVm(id: ID!): Boolean!
}
type Subscription {
vms: Vms
}
type Vms {
id: ID!
domain: [VmDomain!]
}
type Subscription {
vms: Vms
}
# https://libvirt.org/manpages/virsh.html#list
enum VmState {
NOSTATE

View File

@@ -67,6 +67,7 @@ const initialState = {
'auth-keys': resolvePath(
process.env.PATHS_AUTH_KEY ?? ('/boot/config/plugins/dynamix.my.servers/keys' as const)
),
'libvirt-pid': '/var/run/libvirt/libvirtd.pid' as const,
};
export const paths = createSlice({

View File

@@ -1,5 +1,5 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ThrottlerModule } from '@nestjs/throttler';
import { AuthZGuard } from 'nest-authz';
@@ -7,8 +7,9 @@ import { LoggerModule } from 'nestjs-pino';
import { apiLogger } from '@app/core/log.js';
import { LOG_LEVEL } from '@app/environment.js';
import { GraphqlAuthGuard } from '@app/unraid-api/auth/auth.guard.js';
import { AuthInterceptor } from '@app/unraid-api/auth/auth.interceptor.js';
import { AuthModule } from '@app/unraid-api/auth/auth.module.js';
import { AuthenticationGuard } from '@app/unraid-api/auth/authentication.guard.js';
import { CronModule } from '@app/unraid-api/cron/cron.module.js';
import { GraphModule } from '@app/unraid-api/graph/graph.module.js';
import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
@@ -53,7 +54,7 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u
providers: [
{
provide: APP_GUARD,
useClass: GraphqlAuthGuard,
useClass: AuthenticationGuard,
},
{
provide: APP_GUARD,

View File

@@ -9,7 +9,7 @@ import { ZodError } from 'zod';
import type { ApiKey, ApiKeyWithSecret } from '@app/graphql/generated/api/types.js';
import { environment } from '@app/environment.js';
import { ApiKeySchema, ApiKeyWithSecretSchema } from '@app/graphql/generated/api/operations.js';
import { Resource, Role } from '@app/graphql/generated/api/types.js';
import { AuthActionVerb, Resource, Role } from '@app/graphql/generated/api/types.js';
import { getters, store } from '@app/store/index.js';
import { updateUserConfig } from '@app/store/modules/config.js';
import { FileLoadStatus } from '@app/store/types.js';
@@ -82,7 +82,7 @@ describe('ApiKeyService', () => {
permissions: [
{
resource: Resource.CONNECT,
actions: ['read'],
actions: [AuthActionVerb.READ],
},
],
createdAt: new Date().toISOString(),
@@ -601,13 +601,12 @@ describe('ApiKeyService', () => {
}),
} as any);
await expect(apiKeyService['loadApiKeyFile']('test.json')).rejects.toThrow(
'Invalid API key structure'
);
await expect(
apiKeyService['loadApiKeyFile']('test.json')
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Invalid API key structure]`);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('Invalid API key structure in file test.json'),
expect.any(Array)
expect.stringContaining('Invalid API key structure in file test.json')
);
});
});

View File

@@ -78,6 +78,7 @@ export class ApiKeyService implements OnModuleInit {
return permissions.reduce<Array<Permission>>((acc, permission) => {
const [resource, action] = permission.split(':');
const validatedResource = Resource[resource.toUpperCase() as keyof typeof Resource] ?? null;
// Pull the actual enum value from the graphql schema
const validatedAction =
AuthActionVerb[action.toUpperCase() as keyof typeof AuthActionVerb] ?? null;
if (validatedAction && validatedResource) {
@@ -214,7 +215,12 @@ export class ApiKeyService implements OnModuleInit {
try {
const content = await readFile(join(this.basePath, file), 'utf8');
return ApiKeyWithSecretSchema().parse(JSON.parse(content));
// First convert all the strings in roles and permissions to uppercase (this ensures that casing is never an issue)
const parsedContent = JSON.parse(content);
if (parsedContent.roles) {
parsedContent.roles = parsedContent.roles.map((role: string) => role.toUpperCase());
}
return ApiKeyWithSecretSchema().parse(parsedContent);
} catch (error) {
if (error instanceof SyntaxError) {
this.logger.error(`Corrupted key file: ${file}`);
@@ -222,7 +228,7 @@ export class ApiKeyService implements OnModuleInit {
}
if (error instanceof ZodError) {
this.logger.error(`Invalid API key structure in file ${file}`, error.errors);
this.logApiKeyZodError(file, error);
throw new Error('Invalid API key structure');
}
@@ -242,7 +248,7 @@ export class ApiKeyService implements OnModuleInit {
return null;
} catch (error) {
if (error instanceof ZodError) {
this.logger.error('Invalid API key structure', error.errors);
this.logApiKeyZodError(id, error);
throw new Error('Invalid API key structure');
}
throw error;
@@ -267,6 +273,11 @@ export class ApiKeyService implements OnModuleInit {
return crypto.randomBytes(32).toString('hex');
}
private logApiKeyZodError(file: string, error: ZodError): void {
this.logger.error(`Invalid API key structure in file ${file}.
Errors: ${JSON.stringify(error.errors, null, 2)}`);
}
public async createLocalConnectApiKey(): Promise<ApiKeyWithSecret | null> {
try {
return await this.create({
@@ -298,7 +309,7 @@ export class ApiKeyService implements OnModuleInit {
);
} catch (error: unknown) {
if (error instanceof ZodError) {
this.logger.error('Invalid API key structure', error.errors);
this.logApiKeyZodError(apiKey.id, error);
throw new GraphQLError('Failed to save API key: Invalid data structure');
} else if (error instanceof Error) {
throw new GraphQLError(`Failed to save API key: ${error.message}`);

View File

@@ -0,0 +1,25 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
UnauthorizedException,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { Observable } from 'rxjs';
@Injectable()
export class AuthInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const gqlContext = GqlExecutionContext.create(context);
const req = gqlContext.getContext().req;
console.log('in auth interceptor', req.user);
if (!req.user) {
throw new UnauthorizedException('User not authenticated');
}
return next.handle();
}
}

View File

@@ -1,7 +1,7 @@
import { UnauthorizedException } from '@nestjs/common';
import { newEnforcer } from 'casbin';
import { AuthZService } from 'nest-authz';
import { AuthActionVerb, AuthZService } from 'nest-authz';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ApiKey, ApiKeyWithSecret, UserAccount } from '@app/graphql/generated/api/types.js';
@@ -36,7 +36,7 @@ describe('AuthService', () => {
permissions: [
{
resource: Resource.CONNECT,
actions: ['read'],
actions: [AuthActionVerb.READ.toUpperCase()],
},
],
createdAt: new Date().toISOString(),
@@ -120,7 +120,7 @@ describe('AuthService', () => {
const result = await authService.validateCookiesWithCsrfToken(mockRequest);
expect(result).toEqual(mockUser);
expect(addRoleSpy).toHaveBeenCalledWith(mockUser.id, 'guest');
expect(addRoleSpy).toHaveBeenCalledWith(mockUser.id, Role.GUEST);
});
it('should throw UnauthorizedException when CSRF token is invalid', async () => {

View File

@@ -34,11 +34,11 @@ type GraphQLContext =
};
@Injectable()
export class GraphqlAuthGuard
export class AuthenticationGuard
extends AuthGuard([ServerHeaderStrategy.key, UserCookieStrategy.key])
implements CanActivate
{
protected logger = new Logger(GraphqlAuthGuard.name);
protected logger = new Logger(AuthenticationGuard.name);
constructor() {
super();
}

View File

@@ -13,6 +13,6 @@ e = some(where (p.eft == allow))
[matchers]
m = (regexMatch(r.sub, p.sub) || g(r.sub, p.sub)) && \
keyMatch2(r.obj, p.obj) && \
(r.act == p.act || p.act == '*')
regexMatch(lower(r.obj), lower(p.obj)) && \
(regexMatch(lower(r.act), lower(p.act)) || p.act == '*' || regexMatch(lower(r.act), lower(concat(p.act, ':.*'))))
`;

View File

@@ -4,7 +4,7 @@ import { Resource, Role } from '@app/graphql/generated/api/types.js';
export const BASE_POLICY = `
# Admin permissions
p, ${Role.ADMIN}, *, *, *
p, ${Role.ADMIN}, *, *
# Connect Permissions
p, ${Role.CONNECT}, *, ${AuthAction.READ_ANY}

View File

@@ -0,0 +1,241 @@
import { makeExecutableSchema } from '@graphql-tools/schema';
import { Enforcer } from 'casbin';
import { GraphQLResolveInfo, GraphQLSchema } from 'graphql';
import { AuthActionVerb, AuthPossession, AuthZService, UsePermissions } from 'nest-authz';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
authSchemaTransformer,
getAuthEnumTypeDefs,
transformResolvers,
} from '@app/unraid-api/graph/directives/auth.directive.js';
// Mock UsePermissions function
vi.mock('nest-authz', () => ({
AuthActionVerb: {
READ: 'READ',
CREATE: 'CREATE',
UPDATE: 'UPDATE',
DELETE: 'DELETE',
},
AuthPossession: {
OWN: 'OWN',
ANY: 'ANY',
},
UsePermissions: vi.fn(),
}));
describe.skip('Auth Directive', () => {
let schema: GraphQLSchema;
const typeDefs = `
${getAuthEnumTypeDefs()}
type Query {
protectedField: String @auth(action: READ, resource: "USER", possession: OWN)
unprotectedField: String
}
`;
const resolvers = {
Query: {
protectedField: () => 'protected data',
unprotectedField: () => 'public data',
},
};
beforeEach(() => {
const authZService = new AuthZService({} as Enforcer);
// Create schema for each test
schema = makeExecutableSchema({
typeDefs,
resolvers: transformResolvers(resolvers, authZService),
});
// Apply our auth schema transformer
schema = authSchemaTransformer(schema);
// Reset all mocks
vi.clearAllMocks();
});
describe('authSchemaTransformer', () => {
it('should add permission information to field description', () => {
const queryType = schema.getQueryType();
if (!queryType) throw new Error('Query type not found in schema');
const protectedField = queryType.getFields().protectedField;
expect(protectedField.description).toContain('Required Permissions');
expect(protectedField.description).toContain('Action: **READ**');
expect(protectedField.description).toContain('Resource: **USER**');
expect(protectedField.description).toContain('Possession: **OWN**');
});
it('should store permission requirements in field extensions', () => {
const queryType = schema.getQueryType();
if (!queryType) throw new Error('Query type not found in schema');
const protectedField = queryType.getFields().protectedField;
expect(protectedField.extensions).toBeDefined();
expect(protectedField.extensions.requiredPermissions).toEqual({
action: 'READ',
resource: 'USER',
possession: 'OWN',
});
});
it('should not modify fields without auth directive', () => {
const queryType = schema.getQueryType();
if (!queryType) throw new Error('Query type not found in schema');
const unprotectedField = queryType.getFields().unprotectedField;
expect(unprotectedField.extensions?.requiredPermissions).toBeUndefined();
expect(unprotectedField.description).toBeFalsy();
});
});
describe('transformResolvers', () => {
it('should wrap resolvers to check permissions before execution', async () => {
const queryType = schema.getQueryType();
if (!queryType) throw new Error('Query type not found in schema');
const protectedField = queryType.getFields().protectedField;
const mockSource = {};
const mockArgs = {};
const mockContext: { requiredPermissions?: any } = {};
// Instead of mocking GraphQLResolveInfo, we can invoke the wrapped resolver directly
// Create a simple function to extract the resolver and call it with our mock objects
const testResolver = async () => {
if (!schema.getQueryType()) return;
// Get the schema fields
const queryFields = schema.getQueryType()!.getFields();
// Call the manually wrapped resolver
if (queryFields.protectedField && queryFields.protectedField.resolve) {
const result = await queryFields.protectedField.resolve(
mockSource,
mockArgs,
mockContext,
{
fieldName: 'protectedField',
parentType: { name: 'Query' },
schema,
// We need these fields, but they aren't actually used in the auth directive code
fieldNodes: [] as any,
returnType: {} as any,
path: {} as any,
fragments: {} as any,
rootValue: null as any,
operation: {} as any,
variableValues: {} as any,
} as unknown as GraphQLResolveInfo
);
return result;
}
return;
};
await testResolver();
// Check that permissions were set in context
expect(mockContext).toHaveProperty('requiredPermissions');
expect(mockContext.requiredPermissions).toEqual({
action: 'READ',
resource: 'USER',
possession: 'OWN',
});
// Check that UsePermissions was called with the right params
expect(UsePermissions).toHaveBeenCalledWith({
action: 'READ',
resource: 'USER',
possession: 'OWN',
});
});
it('should not apply permissions for unprotected fields', async () => {
const queryType = schema.getQueryType();
if (!queryType) throw new Error('Query type not found in schema');
const unprotectedField = queryType.getFields().unprotectedField;
const mockSource = {};
const mockArgs = {};
const mockContext: { requiredPermissions?: any } = {};
// Instead of mocking GraphQLResolveInfo, we can invoke the wrapped resolver directly
const testResolver = async () => {
if (!schema.getQueryType()) return;
// Get the schema fields
const queryFields = schema.getQueryType()!.getFields();
// Call the manually wrapped resolver
if (queryFields.unprotectedField && queryFields.unprotectedField.resolve) {
const result = await queryFields.unprotectedField.resolve(
mockSource,
mockArgs,
mockContext,
{
fieldName: 'unprotectedField',
parentType: { name: 'Query' },
schema,
// We need these fields, but they aren't actually used in the auth directive code
fieldNodes: [] as any,
returnType: {} as any,
path: {} as any,
fragments: {} as any,
rootValue: null as any,
operation: {} as any,
variableValues: {} as any,
} as unknown as GraphQLResolveInfo
);
return result;
}
return;
};
await testResolver();
// Check that permissions were not set or checked
expect(mockContext.requiredPermissions).toBeUndefined();
expect(UsePermissions).not.toHaveBeenCalled();
});
it('should handle an array of resolvers', () => {
const authZService = new AuthZService({} as Enforcer);
const resolversArray = [
{ Query: { field1: () => 'data' } },
{ Mutation: { field2: () => 'data' } },
] as any; // Type assertion to avoid complex IResolvers typing
const transformed = transformResolvers(resolversArray, authZService);
expect(Array.isArray(transformed)).toBe(true);
expect(transformed).toHaveLength(2);
});
});
describe('getAuthEnumTypeDefs', () => {
it('should generate valid SDL for auth enums', () => {
const typeDefs = getAuthEnumTypeDefs();
expect(typeDefs).toContain('enum AuthActionVerb');
expect(typeDefs).toContain('enum AuthPossession');
expect(typeDefs).toContain('directive @auth');
// Check for enum values
Object.keys(AuthActionVerb)
.filter((key) => isNaN(Number(key)))
.forEach((key) => {
expect(typeDefs).toContain(key);
});
Object.keys(AuthPossession)
.filter((key) => isNaN(Number(key)))
.forEach((key) => {
expect(typeDefs).toContain(key);
});
});
});
});

View File

@@ -0,0 +1,219 @@
import { UnauthorizedException } from '@nestjs/common';
import { getDirective, IResolvers, MapperKind, mapSchema } from '@graphql-tools/utils';
import { GraphQLEnumType, GraphQLSchema } from 'graphql';
import { AuthActionVerb, AuthPossession, AuthZService, BatchApproval } from 'nest-authz';
import { Resource } from '@app/graphql/generated/api/types.js';
/**
* @wip : This function does not correctly apply permission to every field.
* @todo : Once we've determined how to fix the transformResolvers function, uncomment this.
*/
export function transformResolvers(
resolvers: IResolvers | IResolvers[],
authZService: AuthZService
): IResolvers | IResolvers[] {
if (Array.isArray(resolvers)) {
return resolvers.map((r) => transformResolvers(r, authZService)) as IResolvers[];
}
// Iterate over each type in the resolvers object
Object.keys(resolvers).forEach((typeName) => {
const typeResolvers = resolvers[typeName];
// Iterate over each field within the type
Object.keys(typeResolvers).forEach((fieldName) => {
const fieldResolver = typeResolvers[fieldName];
// Skip non-function resolvers (or if it's not defined)
if (typeof fieldResolver !== 'function') {
return;
}
// Check if this field has permission metadata in its extensions property
// We need to wrap the resolver in a function that checks if the user has the required permissions
const originalResolver = fieldResolver;
// Create a wrapped resolver that will extract permissions from info
typeResolvers[fieldName] = async (source, args, context, info) => {
// Access the extensions from the field definition in the schema
console.log(
'resolving',
info.fieldName,
info.parentType.name,
info.schema.getType(info.parentType.name).getFields()[info.fieldName].extensions
);
console.log('user', context?.req?.user);
const fieldExtensions = info.schema.getType(info.parentType.name).getFields()[
info.fieldName
].extensions;
if (fieldExtensions?.requiredPermissions && context?.req?.user) {
const { action, resource, possession } = fieldExtensions.requiredPermissions;
if (context) {
// Handle OWN_ANY possession by checking both ANY and OWN permissions
if (possession === AuthPossession.OWN_ANY) {
context.requiredPermissions = [
{
action: action.toUpperCase(),
resource: resource.toUpperCase(),
possession: AuthPossession.ANY,
},
{
action: action.toUpperCase(),
resource: resource.toUpperCase(),
possession: AuthPossession.OWN,
},
];
// For OWN_ANY, we want to check both ANY and OWN permissions
// If either check passes, the user has permission
const hasPermission = await authZService.enforce(
context.user,
resource.toUpperCase(),
action.toUpperCase(),
BatchApproval.ANY
);
if (!hasPermission) {
throw new UnauthorizedException(
'Unauthorized: User does not have required permissions'
);
}
} else {
context.requiredPermissions = {
action: action.toUpperCase(),
resource: resource.toUpperCase(),
possession: possession.toUpperCase(),
};
// For regular permissions, we check the specific possession type
const hasPermission = await authZService.enforce(
context.user,
resource.toUpperCase(),
`${action.toUpperCase()}:${possession.toUpperCase()}`
);
if (!hasPermission) {
throw new UnauthorizedException(
'Unauthorized: User does not have required permissions'
);
}
}
}
}
// Call the original resolver after permission check
return await originalResolver(source, args, context, info);
};
});
});
return resolvers;
}
export function authSchemaTransformer(schema: GraphQLSchema): GraphQLSchema {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig, fieldName, typeName) => {
const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];
if (authDirective) {
const {
action: actionValue,
resource: resourceValue,
possession: possessionValue,
} = authDirective;
if (!actionValue || !resourceValue || !possessionValue) {
console.warn(
`Auth directive on ${typeName}.${fieldName} is missing required arguments.`
);
return fieldConfig;
}
// Append permission information to the field description
const permissionDoc = `
#### Required Permissions:
- Action: **${actionValue}**
- Resource: **${resourceValue}**
- Possession: **${possessionValue}**`;
const newDescription = fieldConfig.description
? `${fieldConfig.description}${permissionDoc}`
: permissionDoc;
fieldConfig.description = newDescription;
// Store the required permissions in the field config extensions
fieldConfig.extensions = {
...fieldConfig.extensions,
requiredPermissions: {
action: actionValue.toUpperCase() as AuthActionVerb,
resource: resourceValue.toUpperCase() as Resource,
possession: possessionValue.toUpperCase() as AuthPossession,
},
};
}
return fieldConfig;
},
});
}
/**
* Generates GraphQL SDL strings for the authentication enums.
*/
export function getAuthEnumTypeDefs(): string {
// Helper to generate enum values string part with descriptions
const getEnumValues = <T>(tsEnum: Record<string, T>): string => {
return Object.entries(tsEnum)
.filter(([key]) => isNaN(Number(key))) // Filter out numeric keys
.map(([key]) => ` ${key}`)
.join('\n');
};
return `"""
Available authentication action verbs
"""
enum AuthActionVerb {
${getEnumValues(AuthActionVerb)}
}
"""
Available authentication possession types
"""
enum AuthPossession {
${getEnumValues(AuthPossession)}
}
directive @auth(
action: AuthActionVerb!,
resource: String!,
possession: AuthPossession!
) on FIELD_DEFINITION
`;
}
/**
* Generic function to convert TypeScript enums to GraphQL enums
* (Kept for potential other uses, but not used for Auth enums in schema generation anymore)
*/
export function createGraphQLEnumFromTSEnum<T>(
tsEnum: Record<string, T>,
name: string,
description: string
): GraphQLEnumType {
const enumValues = {};
Object.keys(tsEnum).forEach((key) => {
if (isNaN(Number(key))) {
// Skip numeric keys (enum in TS has both string and number keys)
enumValues[key] = { value: tsEnum[key] };
}
});
return new GraphQLEnumType({
name,
description,
values: enumValues,
});
}

View File

@@ -1,8 +1,9 @@
import type { ApolloDriverConfig } from '@nestjs/apollo';
import { ApolloDriver } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { Module, UnauthorizedException } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloServerPlugin } from '@apollo/server';
import { NoUnusedVariablesRule, print } from 'graphql';
import {
DateTimeResolver,
@@ -11,19 +12,16 @@ import {
URLResolver,
UUIDResolver,
} from 'graphql-scalars';
import { AuthZService } from 'nest-authz';
import { GraphQLLong } from '@app/graphql/resolvers/graphql-type-long.js';
import { loadTypeDefs } from '@app/graphql/schema/loadTypesDefs.js';
import { getters } from '@app/store/index.js';
import { ConnectSettingsService } from '@app/unraid-api/graph/connect/connect-settings.service.js';
import { ConnectResolver } from '@app/unraid-api/graph/connect/connect.resolver.js';
import { ConnectService } from '@app/unraid-api/graph/connect/connect.service.js';
import { AuthModule } from '@app/unraid-api/auth/auth.module.js';
import { idPrefixPlugin } from '@app/unraid-api/graph/id-prefix-plugin.js';
import { NetworkResolver } from '@app/unraid-api/graph/network/network.resolver.js';
import { ResolversModule } from '@app/unraid-api/graph/resolvers/resolvers.module.js';
import { sandboxPlugin } from '@app/unraid-api/graph/sandbox-plugin.js';
import { ServicesResolver } from '@app/unraid-api/graph/services/services.resolver.js';
import { SharesResolver } from '@app/unraid-api/graph/shares/shares.resolver.js';
import { getAuthEnumTypeDefs } from '@app/unraid-api/graph/utils/auth-enum.utils.js';
import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
@@ -32,47 +30,51 @@ import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
ResolversModule,
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
imports: [PluginModule],
inject: [PluginService],
useFactory: async (pluginService: PluginService) => {
imports: [PluginModule, AuthModule],
inject: [PluginService, AuthZService],
useFactory: async (pluginService: PluginService, authZService: AuthZService) => {
const plugins = await pluginService.getGraphQLConfiguration();
const authEnumTypeDefs = getAuthEnumTypeDefs();
const typeDefs = print(await loadTypeDefs([plugins.typeDefs, authEnumTypeDefs]));
const resolvers = {
DateTime: DateTimeResolver,
JSON: JSONResolver,
Long: GraphQLLong,
Port: PortResolver,
URL: URLResolver,
UUID: UUIDResolver,
...plugins.resolvers,
};
return {
introspection: getters.config()?.local?.sandbox === 'yes',
playground: false,
context: ({ req, connectionParams, extra }: any) => ({
req,
connectionParams,
extra,
}),
context: async ({ req, connectionParams, extra }) => {
return {
req,
connectionParams,
extra,
};
},
plugins: [sandboxPlugin, idPrefixPlugin] as any[],
subscriptions: {
'graphql-ws': {
path: '/graphql',
},
},
path: '/graphql',
typeDefs: [print(await loadTypeDefs([plugins.typeDefs]))],
resolvers: {
JSON: JSONResolver,
Long: GraphQLLong,
UUID: UUIDResolver,
DateTime: DateTimeResolver,
Port: PortResolver,
URL: URLResolver,
...plugins.resolvers,
},
typeDefs,
resolvers,
/**
* @todo : Once we've determined how to fix the transformResolvers function, uncomment this.
*/
// transformResolvers: (resolvers) => transformResolvers(resolvers, authZService),
// transformSchema: (schema) => authSchemaTransformer(schema),
validationRules: [NoUnusedVariablesRule],
};
},
}),
],
providers: [
NetworkResolver,
ServicesResolver,
SharesResolver,
ConnectResolver,
ConnectService,
ConnectSettingsService,
],
providers: [],
exports: [GraphQLModule],
})
export class GraphModule {}

View File

@@ -1,6 +1,7 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { capitalCase, constantCase } from 'change-case';
import { GraphQLError } from 'graphql';
import type { ArrayDiskInput, ArrayStateInput, ArrayType } from '@app/graphql/generated/api/types.js';
import { AppError } from '@app/core/errors/app-error.js';
@@ -142,4 +143,65 @@ export class ArrayService {
return getArrayData();
}
/**
* Updates the parity check state
* @param wantedState - The desired state for the parity check ('pause', 'resume', 'cancel', 'start')
* @param correct - Whether to write corrections to parity (only applicable for 'start' state)
* @returns The updated array data
*/
async updateParityCheck({
wantedState,
correct,
}: {
wantedState: 'pause' | 'resume' | 'cancel' | 'start';
correct: boolean;
}): Promise<ArrayType> {
const { getters } = await import('@app/store/index.js');
const running = getters.emhttp().var.mdResync !== 0;
const states = {
pause: {
cmdNoCheck: 'Pause',
},
resume: {
cmdCheck: 'Resume',
},
cancel: {
cmdNoCheck: 'Cancel',
},
start: {
cmdCheck: 'Check',
},
};
let allowedStates = Object.keys(states);
// Only allow starting a check if there isn't already one running
if (running) {
// Remove 'start' from allowed states when a check is already running
allowedStates = allowedStates.filter((state) => state !== 'start');
}
// Only allow states from states object
if (!allowedStates.includes(wantedState)) {
throw new GraphQLError(`Invalid parity check state: ${wantedState}`);
}
// Should we write correction to the parity during the check
const writeCorrectionsToParity = wantedState === 'start' && correct;
try {
await emcmd({
startState: 'STARTED',
...states[wantedState],
...(writeCorrectionsToParity ? { optionCorrect: 'correct' } : {}),
});
} catch (error) {
throw new GraphQLError(
`Failed to update parity check: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
return getArrayData();
}
}

View File

@@ -1,8 +1,8 @@
import { Query, Resolver } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { AuthPossession, UsePermissions } from 'nest-authz';
import { Resource } from '@app/graphql/generated/api/types.js';
import { AuthActionVerb, Resource } from '@app/graphql/generated/api/types.js';
@Resolver('Online')
export class OnlineResolver {

View File

@@ -2,6 +2,9 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '@app/unraid-api/auth/auth.module.js';
import { ConnectSettingsService } from '@app/unraid-api/graph/connect/connect-settings.service.js';
import { ConnectResolver } from '@app/unraid-api/graph/connect/connect.resolver.js';
import { ConnectService } from '@app/unraid-api/graph/connect/connect.service.js';
import { NetworkResolver } from '@app/unraid-api/graph/network/network.resolver.js';
import { ApiKeyResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.resolver.js';
import { ArrayMutationsResolver } from '@app/unraid-api/graph/resolvers/array/array.mutations.resolver.js';
import { ArrayResolver } from '@app/unraid-api/graph/resolvers/array/array.resolver.js';
@@ -26,37 +29,48 @@ import { OwnerResolver } from '@app/unraid-api/graph/resolvers/owner/owner.resol
import { RegistrationResolver } from '@app/unraid-api/graph/resolvers/registration/registration.resolver.js';
import { ServerResolver } from '@app/unraid-api/graph/resolvers/servers/server.resolver.js';
import { VarsResolver } from '@app/unraid-api/graph/resolvers/vars/vars.resolver.js';
import { VmMutationsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.mutations.resolver.js';
import { VmsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.resolver.js';
import { VmsService } from '@app/unraid-api/graph/resolvers/vms/vms.service.js';
import { ServicesResolver } from '@app/unraid-api/graph/services/services.resolver.js';
import { SharesResolver } from '@app/unraid-api/graph/shares/shares.resolver.js';
@Module({
imports: [AuthModule],
providers: [
ArrayResolver,
ArrayMutationsResolver,
ArrayService,
ApiKeyResolver,
ArrayMutationsResolver,
ArrayResolver,
ArrayService,
CloudResolver,
ConfigResolver,
ConnectResolver,
ConnectService,
ConnectSettingsService,
DisksResolver,
DisplayResolver,
DockerResolver,
DockerMutationsResolver,
DockerResolver,
DockerService,
FlashResolver,
MutationResolver,
InfoResolver,
LogsResolver,
LogsService,
MeResolver,
MutationResolver,
NetworkResolver,
NotificationsResolver,
NotificationsService,
OnlineResolver,
OwnerResolver,
RegistrationResolver,
ServerResolver,
ServicesResolver,
SharesResolver,
VarsResolver,
VmMutationsResolver,
VmsResolver,
NotificationsService,
MeResolver,
ConnectSettingsService,
LogsResolver,
LogsService,
VmsService,
],
exports: [AuthModule, ApiKeyResolver],
})

View File

@@ -0,0 +1,74 @@
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { VmMutationsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.mutations.resolver.js';
import { VmsService } from '@app/unraid-api/graph/resolvers/vms/vms.service.js';
describe('VmMutationsResolver', () => {
let resolver: VmMutationsResolver;
let vmsService: VmsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
VmMutationsResolver,
{
provide: VmsService,
useValue: {
startVm: vi.fn(),
stopVm: vi.fn(),
},
},
],
}).compile();
resolver = module.get<VmMutationsResolver>(VmMutationsResolver);
vmsService = module.get<VmsService>(VmsService);
});
it('should be defined', () => {
expect(resolver).toBeDefined();
});
describe('startVm', () => {
it('should call service.startVm with the provided id', async () => {
const vmId = 'test-vm-id';
vi.mocked(vmsService.startVm).mockResolvedValue(true);
const result = await resolver.startVm(vmId);
expect(result).toBe(true);
expect(vmsService.startVm).toHaveBeenCalledWith(vmId);
});
it('should propagate errors from the service', async () => {
const vmId = 'test-vm-id';
const error = new Error('Failed to start VM');
vi.mocked(vmsService.startVm).mockRejectedValue(error);
await expect(resolver.startVm(vmId)).rejects.toThrow('Failed to start VM');
});
});
describe('stopVm', () => {
it('should call service.stopVm with the provided id', async () => {
const vmId = 'test-vm-id';
vi.mocked(vmsService.stopVm).mockResolvedValue(true);
const result = await resolver.stopVm(vmId);
expect(result).toBe(true);
expect(vmsService.stopVm).toHaveBeenCalledWith(vmId);
});
it('should propagate errors from the service', async () => {
const vmId = 'test-vm-id';
const error = new Error('Failed to stop VM');
vi.mocked(vmsService.stopVm).mockRejectedValue(error);
await expect(resolver.stopVm(vmId)).rejects.toThrow('Failed to stop VM');
});
});
});

View File

@@ -0,0 +1,81 @@
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import { Resource } from '@app/graphql/generated/api/types.js';
import { VmsService } from '@app/unraid-api/graph/resolvers/vms/vms.service.js';
@Resolver('VmMutations')
export class VmMutationsResolver {
constructor(private readonly vmsService: VmsService) {}
@UsePermissions({
action: AuthActionVerb.UPDATE,
resource: Resource.VMS,
possession: AuthPossession.ANY,
})
@ResolveField('startVm')
async startVm(@Args('id') id: string): Promise<boolean> {
return this.vmsService.startVm(id);
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
resource: Resource.VMS,
possession: AuthPossession.ANY,
})
@ResolveField('stopVm')
async stopVm(@Args('id') id: string): Promise<boolean> {
return this.vmsService.stopVm(id);
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
resource: Resource.VMS,
possession: AuthPossession.ANY,
})
@ResolveField('pauseVm')
async pauseVm(@Args('id') id: string): Promise<boolean> {
return this.vmsService.pauseVm(id);
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
resource: Resource.VMS,
possession: AuthPossession.ANY,
})
@ResolveField('resumeVm')
async resumeVm(@Args('id') id: string): Promise<boolean> {
return this.vmsService.resumeVm(id);
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
resource: Resource.VMS,
possession: AuthPossession.ANY,
})
@ResolveField('forceStopVm')
async forceStopVm(@Args('id') id: string): Promise<boolean> {
return this.vmsService.forceStopVm(id);
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
resource: Resource.VMS,
possession: AuthPossession.ANY,
})
@ResolveField('rebootVm')
async rebootVm(@Args('id') id: string): Promise<boolean> {
return this.vmsService.rebootVm(id);
}
@UsePermissions({
action: AuthActionVerb.UPDATE,
resource: Resource.VMS,
possession: AuthPossession.ANY,
})
@ResolveField('resetVm')
async resetVm(@Args('id') id: string): Promise<boolean> {
return this.vmsService.resetVm(id);
}
}

View File

@@ -1,19 +1,30 @@
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { VmsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.resolver.js';
import { VmsService } from '@app/unraid-api/graph/resolvers/vms/vms.service.js';
describe('VmsResolver', () => {
let resolver: VmsResolver;
let vmsService: VmsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [VmsResolver],
providers: [
VmsResolver,
{
provide: VmsService,
useValue: {
getDomains: vi.fn(),
},
},
],
}).compile();
resolver = module.get<VmsResolver>(VmsResolver);
vmsService = module.get<VmsService>(VmsService);
});
it('should be defined', () => {

View File

@@ -4,9 +4,12 @@ import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import type { VmDomain } from '@app/graphql/generated/api/types.js';
import { Resource } from '@app/graphql/generated/api/types.js';
import { VmsService } from '@app/unraid-api/graph/resolvers/vms/vms.service.js';
@Resolver('Vms')
export class VmsResolver {
constructor(private readonly vmsService: VmsService) {}
@Query()
@UsePermissions({
action: AuthActionVerb.READ,
@@ -22,9 +25,7 @@ export class VmsResolver {
@ResolveField('domain')
public async domain(): Promise<Array<VmDomain>> {
try {
const { getDomains } = await import('@app/core/modules/vms/get-domains.js');
const domains = await getDomains();
return domains;
return await this.vmsService.getDomains();
} catch (error) {
// Consider using a proper logger here
throw new Error(

View File

@@ -0,0 +1,307 @@
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
import { execSync } from 'child_process';
import { existsSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { ConnectListAllDomainsFlags, Hypervisor } from '@unraid/libvirt';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { VmDomain } from '@app/graphql/generated/api/types.js';
import { VmsService } from '@app/unraid-api/graph/resolvers/vms/vms.service.js';
const TEST_VM_NAME = 'test-integration-vm';
const TMP_DIR = tmpdir();
const DISK_IMAGE = join(TMP_DIR, 'test-vm.img');
const DOMAIN_XML = join(TMP_DIR, 'test-vm.xml');
const LIBVIRT_URI = 'qemu:///session'; // Use session for testing
// Helper function to determine architecture-specific configuration
function getArchConfig(): { arch: string; machine: string; emulator: string } {
if (process.platform === 'darwin') {
if (process.arch === 'arm64') {
return {
arch: 'aarch64',
machine: 'virt',
emulator: '/opt/homebrew/bin/qemu-system-aarch64',
};
} else {
return {
arch: 'x86_64',
machine: 'q35',
emulator: '/opt/homebrew/bin/qemu-system-x86_64',
};
}
} else {
if (process.arch === 'arm64') {
return {
arch: 'aarch64',
machine: 'virt',
emulator: '/usr/bin/qemu-system-aarch64',
};
} else {
return {
arch: 'x86_64',
machine: 'q35',
emulator: '/usr/bin/qemu-system-x86_64',
};
}
}
}
// Helper function to verify QEMU installation
const verifyQemuInstallation = () => {
const archConfig = getArchConfig();
const qemuPath = archConfig.emulator;
if (!existsSync(qemuPath)) {
throw new Error(`QEMU not found at ${qemuPath}. Please install QEMU first.`);
}
return archConfig;
};
// Helper function to clean up disk image
const cleanupDiskImage = () => {
try {
if (existsSync(DISK_IMAGE)) {
try {
execSync(`qemu-img info ${DISK_IMAGE} --force-share`);
} catch (error) {
// Ignore errors from force-share
}
execSync(`rm -f ${DISK_IMAGE}`);
}
} catch (error) {
console.error('Error cleaning up files:', error);
}
};
// Helper function to clean up domain XML
const cleanupDomainXml = () => {
try {
if (existsSync(DOMAIN_XML)) {
execSync(`rm -f ${DOMAIN_XML}`);
}
} catch (error) {
console.error('Error cleaning up domain XML:', error);
}
};
// Helper function to clean up domain using libvirt
const cleanupDomain = async (hypervisor: Hypervisor) => {
try {
// Get both active and inactive domains
const activeDomains = await hypervisor.connectListAllDomains(ConnectListAllDomainsFlags.ACTIVE);
const inactiveDomains = await hypervisor.connectListAllDomains(
ConnectListAllDomainsFlags.INACTIVE
);
const domains = [...activeDomains, ...inactiveDomains];
console.log('Found domains during cleanup:', domains);
// Find the test domain
let testDomain: any = null;
for (const domain of domains) {
const name = await hypervisor.domainGetName(domain);
if (name === TEST_VM_NAME) {
testDomain = domain;
break;
}
}
if (testDomain) {
console.log('Found test domain during cleanup');
try {
const info = await hypervisor.domainGetInfo(testDomain);
console.log('Domain state during cleanup:', info.state);
if (info.state === 1) {
// RUNNING
console.log('Domain is running, destroying it');
await hypervisor.domainDestroy(testDomain);
await new Promise((resolve) => setTimeout(resolve, 2000));
}
} catch (error) {
console.error('Error during domain shutdown:', error);
}
try {
console.log('Undefining domain');
await hypervisor.domainUndefine(testDomain);
await new Promise((resolve) => setTimeout(resolve, 2000));
} catch (error) {
console.error('Error during domain undefine:', error);
}
}
} catch (error) {
console.error('Error during domain cleanup:', error);
}
};
// Helper function to verify libvirt connection
const verifyLibvirtConnection = async (hypervisor: Hypervisor) => {
try {
await hypervisor.connectOpen();
return true;
} catch (error) {
console.error('Libvirt connection verification failed:', error);
return false;
}
};
describe('VmsService', () => {
let service: VmsService;
let hypervisor: Hypervisor;
let testVm: VmDomain | null = null;
const archConfig = getArchConfig();
const domainXml = `
<domain type='qemu'>
<name>${TEST_VM_NAME}</name>
<memory unit='KiB'>524288</memory>
<vcpu>1</vcpu>
<os>
<type arch='${archConfig.arch}' machine='${archConfig.machine}'>hvm</type>
<boot dev='hd'/>
</os>
<devices>
<emulator>${archConfig.emulator}</emulator>
<disk type='file' device='disk'>
<driver name='qemu' type='qcow2'/>
<source file='${DISK_IMAGE}'/>
<target dev='vda' bus='virtio'/>
</disk>
<console type='pty'/>
</devices>
</domain>
`;
beforeAll(async () => {
// Override the LIBVIRT_URI environment variable for testing
process.env.LIBVIRT_URI = LIBVIRT_URI;
// Create hypervisor instance for direct libvirt operations
hypervisor = new Hypervisor({ uri: LIBVIRT_URI });
// Verify libvirt connection is working
const isConnectionWorking = await verifyLibvirtConnection(hypervisor);
if (!isConnectionWorking) {
throw new Error(
'Libvirt connection is not working. Please ensure libvirt is running and accessible.'
);
}
const module: TestingModule = await Test.createTestingModule({
providers: [VmsService],
}).compile();
service = module.get<VmsService>(VmsService);
// Initialize the service
await service.onModuleInit();
});
afterAll(async () => {
await cleanupDomain(hypervisor);
cleanupDiskImage();
cleanupDomainXml();
});
beforeEach(async () => {
// Only set up test VM if it doesn't exist
if (!testVm) {
console.log('Setting up test environment...');
console.log('TMP_DIR:', TMP_DIR);
console.log('DISK_IMAGE:', DISK_IMAGE);
console.log('DOMAIN_XML:', DOMAIN_XML);
// Clean up any existing test VM and files
await cleanupDomain(hypervisor);
cleanupDiskImage();
cleanupDomainXml();
// Create a small disk image for the VM
execSync(`qemu-img create -f qcow2 ${DISK_IMAGE} 1G`);
console.log('Created disk image');
// Write domain XML to file
writeFileSync(DOMAIN_XML, domainXml.trim());
console.log('Created domain XML file');
// Define the domain using libvirt
const domain = await hypervisor.domainDefineXML(domainXml.trim());
console.log('Defined domain');
// Wait for the domain to be defined in the service
let retries = 0;
const maxRetries = 5; // Reduced from 10
while (retries < maxRetries && !testVm) {
try {
const domains = await service.getDomains();
testVm = domains.find((d) => d.name === TEST_VM_NAME) ?? null;
if (testVm) break;
} catch (error) {
console.error('Error getting domains from service:', error);
}
await new Promise((resolve) => setTimeout(resolve, 1000)); // Reduced from 2000
retries++;
}
if (!testVm) {
throw new Error('Failed to find test VM in service after defining it');
}
}
});
it('should list domains including our test VM', async () => {
const domains = await service.getDomains();
const testVm = domains.find((d) => d.name === TEST_VM_NAME);
expect(testVm).toBeDefined();
expect(testVm?.state).toBe('SHUTOFF');
});
it('should start and stop the test VM', async () => {
expect(testVm).toBeDefined();
expect(testVm?.uuid).toBeDefined();
// Start the VM
const startResult = await service.startVm(testVm!.uuid);
expect(startResult).toBe(true);
// Wait for VM to start with a more targeted approach
let isRunning = false;
let attempts = 0;
while (!isRunning && attempts < 5) {
const runningDomains = await service.getDomains();
const runningTestVm = runningDomains.find((d) => d.name === TEST_VM_NAME);
isRunning = runningTestVm?.state === 'RUNNING';
if (!isRunning) {
await new Promise((resolve) => setTimeout(resolve, 1000));
attempts++;
}
}
expect(isRunning).toBe(true);
// Stop the VM
const stopResult = await service.stopVm(testVm!.uuid);
expect(stopResult).toBe(true);
// Wait for VM to stop with a more targeted approach
let isStopped = false;
attempts = 0;
while (!isStopped && attempts < 5) {
const stoppedDomains = await service.getDomains();
const stoppedTestVm = stoppedDomains.find((d) => d.name === TEST_VM_NAME);
isStopped = stoppedTestVm?.state === 'SHUTOFF';
if (!isStopped) {
await new Promise((resolve) => setTimeout(resolve, 1000));
attempts++;
}
}
expect(isStopped).toBe(true);
}, 15000); // Reduced timeout from 30000
it('should handle errors when VM is not available', async () => {
await expect(service.startVm('999')).rejects.toThrow('Failed to set VM state: Invalid UUID');
await expect(service.stopVm('999')).rejects.toThrow('Failed to set VM state: Invalid UUID');
});
});

View File

@@ -0,0 +1,328 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { constants } from 'fs';
import { access } from 'fs/promises';
import type { Domain, DomainInfo, Hypervisor as HypervisorClass } from '@unraid/libvirt';
import { ConnectListAllDomainsFlags, DomainState, Hypervisor } from '@unraid/libvirt';
import { GraphQLError } from 'graphql';
import { libvirtLogger } from '@app/core/log.js';
import { VmDomain, VmState } from '@app/graphql/generated/api/types.js';
import { getters } from '@app/store/index.js';
@Injectable()
export class VmsService implements OnModuleInit {
private hypervisor: InstanceType<typeof HypervisorClass> | null = null;
private isVmsAvailable: boolean = false;
private uri: string;
constructor() {
this.uri = process.env.LIBVIRT_URI ?? 'qemu:///system';
}
private async isLibvirtRunning(): Promise<boolean> {
// Skip PID check for session URIs
if (this.uri.includes('session')) {
return true;
}
try {
await access(getters.paths()['libvirt-pid'], constants.F_OK | constants.R_OK);
return true;
} catch (error) {
return false;
}
}
async onModuleInit() {
try {
libvirtLogger.info(`Initializing VMs service with URI: ${this.uri}`);
await this.initializeHypervisor();
this.isVmsAvailable = true;
libvirtLogger.info(`VMs service initialized successfully with URI: ${this.uri}`);
} catch (error) {
this.isVmsAvailable = false;
libvirtLogger.warn(
`VMs are not available: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
private async initializeHypervisor(): Promise<void> {
libvirtLogger.info('Checking if libvirt is running...');
const running = await this.isLibvirtRunning();
if (!running) {
throw new Error('Libvirt is not running');
}
libvirtLogger.info('Libvirt is running, creating hypervisor instance...');
this.hypervisor = new Hypervisor({ uri: this.uri });
try {
libvirtLogger.info('Attempting to connect to hypervisor...');
await this.hypervisor.connectOpen();
libvirtLogger.info('Successfully connected to hypervisor');
} catch (error) {
libvirtLogger.error(
`Failed starting VM hypervisor connection with "${(error as Error).message}"`
);
throw error;
}
if (!this.hypervisor) {
throw new Error('Failed to connect to hypervisor');
}
}
public async setVmState(uuid: string, targetState: VmState): Promise<boolean> {
if (!this.isVmsAvailable || !this.hypervisor) {
throw new GraphQLError('VMs are not available');
}
try {
libvirtLogger.info(`Looking up domain with UUID: ${uuid}`);
const domain = await this.hypervisor.domainLookupByUUIDString(uuid);
libvirtLogger.info(`Found domain, getting info...`);
const info = await domain.getInfo();
libvirtLogger.info(`Current domain state: ${info.state}`);
// Map VmState to DomainState for comparison
const currentState = this.mapDomainStateToVmState(info.state);
// Validate state transition
if (!this.isValidStateTransition(currentState, targetState)) {
throw new Error(`Invalid state transition from ${currentState} to ${targetState}`);
}
// Perform state transition
switch (targetState) {
case VmState.RUNNING:
if (currentState === VmState.SHUTOFF) {
libvirtLogger.info(`Starting domain...`);
await domain.create();
} else if (currentState === VmState.PAUSED) {
libvirtLogger.info(`Resuming domain...`);
await domain.resume();
}
break;
case VmState.SHUTOFF:
if (currentState === VmState.RUNNING || currentState === VmState.PAUSED) {
libvirtLogger.info(`Initiating graceful shutdown for domain...`);
await domain.shutdown();
const shutdownSuccess = await this.waitForDomainShutdown(domain);
if (!shutdownSuccess) {
libvirtLogger.info('Graceful shutdown failed, forcing domain stop...');
await domain.destroy();
}
}
break;
case VmState.PAUSED:
if (currentState === VmState.RUNNING) {
libvirtLogger.info(`Pausing domain...`);
await domain.suspend();
}
break;
default:
throw new Error(`Unsupported target state: ${targetState}`);
}
return true;
} catch (error) {
throw new GraphQLError(
`Failed to set VM state: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
private mapDomainStateToVmState(domainState: DomainState): VmState {
switch (domainState) {
case DomainState.RUNNING:
return VmState.RUNNING;
case DomainState.BLOCKED:
return VmState.IDLE;
case DomainState.PAUSED:
return VmState.PAUSED;
case DomainState.SHUTDOWN:
return VmState.SHUTDOWN;
case DomainState.SHUTOFF:
return VmState.SHUTOFF;
case DomainState.CRASHED:
return VmState.CRASHED;
case DomainState.PMSUSPENDED:
return VmState.PMSUSPENDED;
default:
return VmState.NOSTATE;
}
}
private isValidStateTransition(currentState: VmState, targetState: VmState): boolean {
// Define valid state transitions
const validTransitions: Record<VmState, VmState[]> = {
[VmState.NOSTATE]: [VmState.RUNNING, VmState.SHUTOFF],
[VmState.RUNNING]: [VmState.PAUSED, VmState.SHUTOFF],
[VmState.IDLE]: [VmState.RUNNING, VmState.SHUTOFF],
[VmState.PAUSED]: [VmState.RUNNING, VmState.SHUTOFF],
[VmState.SHUTDOWN]: [VmState.SHUTOFF],
[VmState.SHUTOFF]: [VmState.RUNNING],
[VmState.CRASHED]: [VmState.SHUTOFF],
[VmState.PMSUSPENDED]: [VmState.RUNNING, VmState.SHUTOFF],
};
return validTransitions[currentState]?.includes(targetState) ?? false;
}
public async startVm(uuid: string): Promise<boolean> {
return this.setVmState(uuid, VmState.RUNNING);
}
public async stopVm(uuid: string): Promise<boolean> {
return this.setVmState(uuid, VmState.SHUTOFF);
}
public async pauseVm(uuid: string): Promise<boolean> {
return this.setVmState(uuid, VmState.PAUSED);
}
public async resumeVm(uuid: string): Promise<boolean> {
return this.setVmState(uuid, VmState.RUNNING);
}
public async forceStopVm(uuid: string): Promise<boolean> {
if (!this.isVmsAvailable || !this.hypervisor) {
throw new GraphQLError('VMs are not available');
}
try {
libvirtLogger.info(`Looking up domain with UUID: ${uuid}`);
const domain = await this.hypervisor.domainLookupByUUIDString(uuid);
libvirtLogger.info(`Found domain, force stopping...`);
await domain.destroy();
return true;
} catch (error) {
throw new GraphQLError(
`Failed to force stop VM: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
public async rebootVm(uuid: string): Promise<boolean> {
if (!this.isVmsAvailable || !this.hypervisor) {
throw new GraphQLError('VMs are not available');
}
try {
libvirtLogger.info(`Looking up domain with UUID: ${uuid}`);
const domain = await this.hypervisor.domainLookupByUUIDString(uuid);
libvirtLogger.info(`Found domain, rebooting...`);
// First try graceful shutdown
await domain.shutdown();
// Wait for shutdown to complete
const shutdownSuccess = await this.waitForDomainShutdown(domain);
if (!shutdownSuccess) {
throw new Error('Graceful shutdown failed, please force stop the VM and try again');
}
// Start the domain again
await domain.create();
return true;
} catch (error) {
throw new GraphQLError(
`Failed to reboot VM: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
public async resetVm(uuid: string): Promise<boolean> {
if (!this.isVmsAvailable || !this.hypervisor) {
throw new GraphQLError('VMs are not available');
}
try {
libvirtLogger.info(`Looking up domain with UUID: ${uuid}`);
const domain = await this.hypervisor.domainLookupByUUIDString(uuid);
libvirtLogger.info(`Found domain, resetting...`);
// Force stop the domain
await domain.destroy();
// Start the domain again
await domain.create();
return true;
} catch (error) {
throw new GraphQLError(
`Failed to reset VM: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
public async getDomains(): Promise<Array<VmDomain>> {
if (!this.isVmsAvailable) {
throw new GraphQLError('VMs are not available');
}
if (!this.hypervisor) {
throw new GraphQLError('Libvirt is not initialized');
}
try {
const hypervisor = this.hypervisor;
libvirtLogger.info('Getting all domains...');
// Get both active and inactive domains
const domains = await hypervisor.connectListAllDomains(
ConnectListAllDomainsFlags.ACTIVE | ConnectListAllDomainsFlags.INACTIVE
);
libvirtLogger.info(`Found ${domains.length} domains`);
const resolvedDomains: Array<VmDomain> = await Promise.all(
domains.map(async (domain) => {
const info = await domain.getInfo();
const name = await domain.getName();
const uuid = await domain.getUUIDString();
libvirtLogger.info(
`Found domain: ${name} (${uuid}) with state ${DomainState[info.state]}`
);
// Map DomainState to VmState using our existing function
const state = this.mapDomainStateToVmState(info.state);
return {
name,
uuid,
state,
};
})
);
return resolvedDomains;
} catch (error: unknown) {
// If we hit an error expect libvirt to be offline
this.isVmsAvailable = false;
libvirtLogger.error(
`Failed to get domains: ${error instanceof Error ? error.message : 'Unknown error'}`
);
throw new GraphQLError(
`Failed to get domains: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
private async waitForDomainShutdown(domain: Domain, maxRetries: number = 10): Promise<boolean> {
if (!this.hypervisor) {
throw new Error('Hypervisor is not initialized');
}
for (let i = 0; i < maxRetries; i++) {
const currentInfo = await this.hypervisor.domainGetInfo(domain);
if (currentInfo.state === DomainState.SHUTOFF) {
libvirtLogger.info('Domain shutdown completed successfully');
return true;
}
libvirtLogger.debug(`Waiting for domain shutdown... (attempt ${i + 1}/${maxRetries})`);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
libvirtLogger.warn(`Domain shutdown timed out after ${maxRetries} attempts`);
return false;
}
}

View File

@@ -0,0 +1,36 @@
import { AuthPossession } from 'nest-authz';
import { AuthActionVerb } from 'nest-authz/dist/src/types.js';
/**
* Generates GraphQL SDL strings for the authentication enums.
*/
export function getAuthEnumTypeDefs(): string {
// Helper to generate enum values string part with descriptions
const getEnumValues = <T>(tsEnum: Record<string, T>): string => {
return Object.entries(tsEnum)
.filter(([key]) => isNaN(Number(key))) // Filter out numeric keys
.map(([key]) => ` ${key}`)
.join('\n');
};
return `"""
Available authentication action verbs
"""
enum AuthActionVerb {
${getEnumValues(AuthActionVerb)}
}
"""
Available authentication possession types
"""
enum AuthPossession {
${getEnumValues(AuthPossession)}
}
directive @auth(
action: AuthActionVerb!,
resource: String!,
possession: AuthPossession!
) on FIELD_DEFINITION
`;
}

View File

@@ -106,13 +106,13 @@ if (!file_exists($notes)) file_put_contents($notes,shell_exec("$docroot/plugins/
String.prototype.actionName = function(){return this.split(/[\\/]/g).pop();}
String.prototype.channel = function(){return this.split(':')[1].split(',').findIndex((e)=>/\[\d\]/.test(e));}
NchanSubscriber.prototype.monitor = function(){subscribers.push(this);}
Shadowbox.init({skipSetup:true});
context.init();
// list of nchan subscribers to start/stop at focus change
var subscribers = [];
// server uptime
var uptime = <?=strtok(exec("cat /proc/uptime"),' ')?>;
var expiretime = <?=_var($var,'regTy')=='Trial'||strstr(_var($var,'regTy'),'expired')?_var($var,'regTm2'):0?>;
@@ -758,8 +758,7 @@ unset($buttons,$button);
// Build page content
// Reload page every X minutes during extended viewing?
if (isset($myPage['Load']) && $myPage['Load'] > 0) echo "\n<script>timers.reload = setInterval(function(){if (nchanPaused === false)location.reload();},".($myPage['Load']*60000).");</script>\n";
echo "<div class='tabs'>";
if (isset($myPage['Load']) && $myPage['Load']>0) echo "\n<script>timers.reload = setInterval(function(){if (nchanPaused === false)location.reload();},".($myPage['Load']*60000).");</script>\n";echo "<div class='tabs'>";
$tab = 1;
$pages = [];
if (!empty($myPage['text'])) $pages[$myPage['name']] = $myPage;
@@ -1271,7 +1270,7 @@ $('body').on('click','a,.ca_href', function(e) {
}
}
});
// Start & stop live updates when window loses focus
var nchanPaused = false;
var blurTimer = false;
@@ -1293,7 +1292,7 @@ document.addEventListener("visibilitychange", (event) => {
<? if ( $display['liveUpdate'] == "no" ):?>
if (document.hidden) {
nchanFocusStop();
}
}
<?else:?>
if (document.hidden) {
nchanFocusStop();
@@ -1312,15 +1311,15 @@ function nchanFocusStart() {
if (nchanPaused !== false ) {
removeBannerWarning(nchanPaused);
nchanPaused = false;
try {
pageFocusFunction();
} catch(error) {}
subscribers.forEach(function(e) {
e.start();
});
}
}
}
function nchanFocusStop(banner=true) {

View File

@@ -106,13 +106,13 @@ if (!file_exists($notes)) file_put_contents($notes,shell_exec("$docroot/plugins/
String.prototype.actionName = function(){return this.split(/[\\/]/g).pop();}
String.prototype.channel = function(){return this.split(':')[1].split(',').findIndex((e)=>/\[\d\]/.test(e));}
NchanSubscriber.prototype.monitor = function(){subscribers.push(this);}
Shadowbox.init({skipSetup:true});
context.init();
// list of nchan subscribers to start/stop at focus change
var subscribers = [];
// server uptime
var uptime = <?=strtok(exec("cat /proc/uptime"),' ')?>;
var expiretime = <?=_var($var,'regTy')=='Trial'||strstr(_var($var,'regTy'),'expired')?_var($var,'regTm2'):0?>;
@@ -749,8 +749,7 @@ unset($buttons,$button);
// Build page content
// Reload page every X minutes during extended viewing?
if (isset($myPage['Load']) && $myPage['Load'] > 0) echo "\n<script>timers.reload = setInterval(function(){if (nchanPaused === false)location.reload();},".($myPage['Load']*60000).");</script>\n";
echo "<div class='tabs'>";
if (isset($myPage['Load']) && $myPage['Load']>0) echo "\n<script>timers.reload = setInterval(function(){if (nchanPaused === false)location.reload();},".($myPage['Load']*60000).");</script>\n";echo "<div class='tabs'>";
$tab = 1;
$pages = [];
if (!empty($myPage['text'])) $pages[$myPage['name']] = $myPage;
@@ -1254,7 +1253,7 @@ $('body').on('click','a,.ca_href', function(e) {
}
}
});
// Start & stop live updates when window loses focus
var nchanPaused = false;
var blurTimer = false;
@@ -1276,7 +1275,7 @@ document.addEventListener("visibilitychange", (event) => {
<? if ( $display['liveUpdate'] == "no" ):?>
if (document.hidden) {
nchanFocusStop();
}
}
<?else:?>
if (document.hidden) {
nchanFocusStop();
@@ -1295,15 +1294,15 @@ function nchanFocusStart() {
if (nchanPaused !== false ) {
removeBannerWarning(nchanPaused);
nchanPaused = false;
try {
pageFocusFunction();
} catch(error) {}
subscribers.forEach(function(e) {
e.start();
});
}
}
}
function nchanFocusStop(banner=true) {

View File

@@ -38,7 +38,7 @@ Index: /usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php
foreach ($buttons as $button) {
annotate($button['file']);
// include page specific stylesheets (if existing)
@@ -944,26 +935,18 @@
@@ -943,26 +934,18 @@
case 'warning': bell2++; break;
case 'normal' : bell3++; break;
}
@@ -70,7 +70,7 @@ Index: /usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php
});
<?if ($wlan0):?>
@@ -1340,7 +1323,8 @@
@@ -1339,7 +1322,8 @@
}
}
}

View File

@@ -169,6 +169,7 @@ export default defineConfig(({ mode }): ViteUserConfig => {
'reflect-metadata',
'src/__test__/setup/env-setup.ts',
'src/__test__/setup/keyserver-mock.ts',
'src/__test__/setup/config-setup.ts',
],
exclude: ['**/deploy/**', '**/node_modules/**'],
},

159
pnpm-lock.yaml generated
View File

@@ -90,8 +90,8 @@ importers:
specifier: ^7.0.1
version: 7.0.2
'@unraid/libvirt':
specifier: ^1.1.3
version: 1.1.3
specifier: ^2.1.0
version: 2.1.0
accesscontrol:
specifier: ^2.2.1
version: 2.2.1
@@ -1189,11 +1189,6 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/parser@7.26.9':
resolution: {integrity: sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/parser@7.27.0':
resolution: {integrity: sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==}
engines: {node: '>=6.0.0'}
@@ -3917,8 +3912,8 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@unraid/libvirt@1.1.3':
resolution: {integrity: sha512-aZNHkwgQ/0e+5BE7i3Ru4GC3Ev8fEUlnU0wmTcuSbpN0r74rMpiGwzA/4cqIJU8X+Kj//I80pkUufzXzHmMWwQ==}
'@unraid/libvirt@2.1.0':
resolution: {integrity: sha512-yF/sAzYukM+VpDdAebf0z0O2mE5mGOEAW5lIafFkYoMiu60zNkNmr5IoA9hWCMmQkBOUCam8RZGs9YNwRjMtMQ==}
engines: {node: '>=14'}
os: [linux, darwin]
@@ -8491,8 +8486,8 @@ packages:
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-addon-api@8.3.0:
resolution: {integrity: sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==}
node-addon-api@8.3.1:
resolution: {integrity: sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA==}
engines: {node: ^18 || ^20 || >= 21}
node-cache@5.1.2:
@@ -9521,7 +9516,6 @@ packages:
engines: {node: '>=0.6.0', teleport: '>=0.2.0'}
deprecated: |-
You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.
(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)
qs@6.13.0:
@@ -11994,7 +11988,7 @@ snapshots:
'@babel/traverse': 7.26.10
'@babel/types': 7.26.10
convert-source-map: 2.0.0
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -12106,10 +12100,6 @@ snapshots:
dependencies:
'@babel/types': 7.26.8
'@babel/parser@7.26.9':
dependencies:
'@babel/types': 7.27.0
'@babel/parser@7.27.0':
dependencies:
'@babel/types': 7.27.0
@@ -12369,7 +12359,7 @@ snapshots:
'@babel/parser': 7.27.0
'@babel/template': 7.26.9
'@babel/types': 7.27.0
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
globals: 11.12.0
transitivePeerDependencies:
- supports-color
@@ -12381,7 +12371,7 @@ snapshots:
'@babel/parser': 7.26.8
'@babel/template': 7.26.8
'@babel/types': 7.26.8
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
globals: 11.12.0
transitivePeerDependencies:
- supports-color
@@ -12699,7 +12689,7 @@ snapshots:
'@eslint/config-array@0.19.2':
dependencies:
'@eslint/object-schema': 2.1.6
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
minimatch: 3.1.2
transitivePeerDependencies:
- supports-color
@@ -12713,7 +12703,7 @@ snapshots:
bundle-require: 5.1.0(esbuild@0.25.1)
cac: 6.7.14
chokidar: 4.0.3
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
esbuild: 0.25.1
eslint: 9.23.0(jiti@2.4.2)
find-up: 7.0.0
@@ -12736,7 +12726,7 @@ snapshots:
'@eslint/eslintrc@3.3.1':
dependencies:
ajv: 6.12.6
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
espree: 10.3.0
globals: 14.0.0
ignore: 5.3.2
@@ -13335,12 +13325,12 @@ snapshots:
'@types/js-yaml': 4.0.9
'@whatwg-node/fetch': 0.10.3
chalk: 4.1.2
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
dotenv: 16.4.7
graphql: 16.10.0
graphql-request: 6.1.0(graphql@16.10.0)
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
https-proxy-agent: 7.0.6(supports-color@9.4.0)
jose: 5.10.0
js-yaml: 4.1.0
lodash: 4.17.21
@@ -13585,7 +13575,7 @@ snapshots:
'@koa/router@12.0.2':
dependencies:
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
http-errors: 2.0.0
koa-compose: 4.1.0
methods: 1.1.2
@@ -13595,7 +13585,7 @@ snapshots:
'@kwsites/file-exists@1.1.1':
dependencies:
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
transitivePeerDependencies:
- supports-color
@@ -13636,7 +13626,7 @@ snapshots:
dependencies:
consola: 3.4.2
detect-libc: 2.0.3
https-proxy-agent: 7.0.6
https-proxy-agent: 7.0.6(supports-color@9.4.0)
node-fetch: 2.7.0
nopt: 8.1.0
semver: 7.7.1
@@ -14418,7 +14408,7 @@ snapshots:
'@pm2/pm2-version-check@1.0.4':
dependencies:
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
transitivePeerDependencies:
- supports-color
@@ -15319,7 +15309,7 @@ snapshots:
'@typescript-eslint/types': 8.28.0
'@typescript-eslint/typescript-estree': 8.28.0(typescript@5.8.2)
'@typescript-eslint/visitor-keys': 8.28.0
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
eslint: 9.23.0(jiti@2.4.2)
typescript: 5.8.2
transitivePeerDependencies:
@@ -15339,7 +15329,7 @@ snapshots:
dependencies:
'@typescript-eslint/typescript-estree': 8.28.0(typescript@5.8.2)
'@typescript-eslint/utils': 8.28.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.2)
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
eslint: 9.23.0(jiti@2.4.2)
ts-api-utils: 2.1.0(typescript@5.8.2)
typescript: 5.8.2
@@ -15367,7 +15357,7 @@ snapshots:
dependencies:
'@typescript-eslint/types': 8.28.0
'@typescript-eslint/visitor-keys': 8.28.0
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
fast-glob: 3.3.3
is-glob: 4.0.3
minimatch: 9.0.5
@@ -15442,12 +15432,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@unraid/libvirt@1.1.3':
'@unraid/libvirt@2.1.0':
dependencies:
'@mapbox/node-pre-gyp': 2.0.0
bindings: 1.5.0
node-addon-api: 8.3.0
node-addon-api: 8.3.1
tsx: 4.19.3
xml2js: 0.6.2
transitivePeerDependencies:
- encoding
- supports-color
'@unraid/shared-callbacks@1.0.1(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2)))':
dependencies:
@@ -15535,7 +15529,7 @@ snapshots:
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6
@@ -15553,7 +15547,7 @@ snapshots:
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6
@@ -15791,7 +15785,7 @@ snapshots:
'@vue/compiler-sfc@3.5.13':
dependencies:
'@babel/parser': 7.26.9
'@babel/parser': 7.27.0
'@vue/compiler-core': 3.5.13
'@vue/compiler-dom': 3.5.13
'@vue/compiler-ssr': 3.5.13
@@ -17634,14 +17628,14 @@ snapshots:
docker-event-emitter@0.3.0(dockerode@3.3.5):
dependencies:
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
dockerode: 3.3.5
transitivePeerDependencies:
- supports-color
docker-modem@3.0.8:
dependencies:
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
readable-stream: 3.6.2
split-ca: 1.0.1
ssh2: 1.16.0
@@ -17950,7 +17944,7 @@ snapshots:
esbuild-register@3.6.0(esbuild@0.25.1):
dependencies:
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
esbuild: 0.25.1
transitivePeerDependencies:
- supports-color
@@ -18115,7 +18109,7 @@ snapshots:
dependencies:
'@types/doctrine': 0.0.9
'@typescript-eslint/utils': 8.28.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.2)
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
doctrine: 3.0.0
eslint: 9.23.0(jiti@2.4.2)
eslint-import-resolver-node: 0.3.9
@@ -18163,7 +18157,7 @@ snapshots:
'@es-joy/jsdoccomment': 0.49.0
are-docs-informative: 0.0.2
comment-parser: 1.4.1
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
escape-string-regexp: 4.0.0
eslint: 9.23.0(jiti@2.4.2)
espree: 10.3.0
@@ -18289,7 +18283,7 @@ snapshots:
ajv: 6.12.6
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
escape-string-regexp: 4.0.0
eslint-scope: 8.3.0
eslint-visitor-keys: 4.2.0
@@ -18833,7 +18827,7 @@ snapshots:
dependencies:
basic-ftp: 5.0.5
data-uri-to-buffer: 6.0.2
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
transitivePeerDependencies:
- supports-color
@@ -19300,7 +19294,7 @@ snapshots:
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.3
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
transitivePeerDependencies:
- supports-color
@@ -19344,13 +19338,6 @@ snapshots:
quick-lru: 5.1.1
resolve-alpn: 1.2.1
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.3
debug: 4.4.0(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.6(supports-color@9.4.0):
dependencies:
agent-base: 7.1.3
@@ -19401,7 +19388,7 @@ snapshots:
importx@0.4.4:
dependencies:
bundle-require: 5.1.0(esbuild@0.23.1)
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
esbuild: 0.23.1
jiti: 2.0.0-beta.3
jiti-v1: jiti@1.21.7
@@ -19495,7 +19482,7 @@ snapshots:
dependencies:
'@ioredis/commands': 1.2.0
cluster-key-slot: 1.1.2
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
denque: 2.1.0
lodash.defaults: 4.2.0
lodash.isarguments: 3.1.0
@@ -19782,7 +19769,7 @@ snapshots:
istanbul-lib-source-maps@5.0.6:
dependencies:
'@jridgewell/trace-mapping': 0.3.25
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
istanbul-lib-coverage: 3.2.2
transitivePeerDependencies:
- supports-color
@@ -19869,7 +19856,7 @@ snapshots:
form-data: 4.0.2
html-encoding-sniffer: 4.0.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
https-proxy-agent: 7.0.6(supports-color@9.4.0)
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.16
parse5: 7.2.1
@@ -19984,7 +19971,7 @@ snapshots:
koa-send@5.0.1:
dependencies:
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
http-errors: 1.8.1
resolve-path: 1.4.0
transitivePeerDependencies:
@@ -20004,7 +19991,7 @@ snapshots:
content-disposition: 0.5.4
content-type: 1.0.5
cookies: 0.9.1
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
delegates: 1.0.0
depd: 2.0.0
destroy: 1.2.0
@@ -20630,7 +20617,7 @@ snapshots:
node-addon-api@7.1.1: {}
node-addon-api@8.3.0: {}
node-addon-api@8.3.1: {}
node-cache@5.1.2:
dependencies:
@@ -21119,10 +21106,10 @@ snapshots:
dependencies:
'@tootallnate/quickjs-emscripten': 0.23.0
agent-base: 7.1.3
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
get-uri: 6.0.4
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
https-proxy-agent: 7.0.6(supports-color@9.4.0)
pac-resolver: 7.0.1
socks-proxy-agent: 8.0.5
transitivePeerDependencies:
@@ -21386,7 +21373,7 @@ snapshots:
pm2-axon-rpc@0.7.1:
dependencies:
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
transitivePeerDependencies:
- supports-color
@@ -21394,7 +21381,7 @@ snapshots:
dependencies:
amp: 0.3.1
amp-message: 0.1.2
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
escape-string-regexp: 4.0.0
transitivePeerDependencies:
- supports-color
@@ -21411,7 +21398,7 @@ snapshots:
pm2-sysmonit@1.2.8:
dependencies:
async: 3.2.6
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
pidusage: 2.0.21
systeminformation: 5.25.11
tx2: 1.0.5
@@ -21433,7 +21420,7 @@ snapshots:
commander: 2.15.1
croner: 4.1.97
dayjs: 1.11.13
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
enquirer: 2.3.6
eventemitter2: 5.0.1
fclone: 1.0.11
@@ -21472,7 +21459,7 @@ snapshots:
portfinder@1.0.35:
dependencies:
async: 3.2.6
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
transitivePeerDependencies:
- supports-color
@@ -21677,7 +21664,7 @@ snapshots:
postcss-styl@0.12.3:
dependencies:
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
fast-diff: 1.3.0
lodash.sortedlastindex: 4.1.0
postcss: 8.5.3
@@ -21772,9 +21759,9 @@ snapshots:
proxy-agent@6.4.0:
dependencies:
agent-base: 7.1.3
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
https-proxy-agent: 7.0.6(supports-color@9.4.0)
lru-cache: 7.18.3
pac-proxy-agent: 7.1.0
proxy-from-env: 1.1.0
@@ -22161,7 +22148,7 @@ snapshots:
require-in-the-middle@5.2.0:
dependencies:
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
module-details-from-path: 1.0.3
resolve: 1.22.10
transitivePeerDependencies:
@@ -22570,7 +22557,7 @@ snapshots:
dependencies:
'@kwsites/file-exists': 1.1.1
'@kwsites/promise-deferred': 1.1.1
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
transitivePeerDependencies:
- supports-color
@@ -22628,7 +22615,7 @@ snapshots:
socks-proxy-agent@8.0.5:
dependencies:
agent-base: 7.1.3
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
socks: 2.8.4
transitivePeerDependencies:
- supports-color
@@ -22884,7 +22871,7 @@ snapshots:
stylus@0.57.0:
dependencies:
css: 3.0.0
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
glob: 7.2.3
safer-buffer: 2.1.2
sax: 1.2.4
@@ -23615,7 +23602,7 @@ snapshots:
vite-node@3.0.9(@types/node@22.13.13)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0):
dependencies:
cac: 6.7.14
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
es-module-lexer: 1.6.0
pathe: 2.0.3
vite: 6.2.3(@types/node@22.13.13)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0)
@@ -23636,7 +23623,7 @@ snapshots:
vite-node@3.0.9(@types/node@22.14.0)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0):
dependencies:
cac: 6.7.14
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
es-module-lexer: 1.6.0
pathe: 2.0.3
vite: 6.2.3(@types/node@22.14.0)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0)
@@ -23657,7 +23644,7 @@ snapshots:
vite-node@3.1.1(@types/node@22.13.13)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0):
dependencies:
cac: 6.7.14
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
es-module-lexer: 1.6.0
pathe: 2.0.3
vite: 6.2.3(@types/node@22.13.13)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0)
@@ -23699,7 +23686,7 @@ snapshots:
'@microsoft/api-extractor': 7.43.0(@types/node@22.13.13)
'@rollup/pluginutils': 5.1.4(rollup@4.37.0)
'@vue/language-core': 1.8.27(typescript@5.8.2)
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
kolorist: 1.8.0
magic-string: 0.30.17
typescript: 5.8.2
@@ -23715,7 +23702,7 @@ snapshots:
dependencies:
'@antfu/utils': 0.7.10
'@rollup/pluginutils': 5.1.4(rollup@4.37.0)
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
error-stack-parser-es: 0.1.5
fs-extra: 11.3.0
open: 10.1.0
@@ -23730,7 +23717,7 @@ snapshots:
vite-plugin-inspect@11.0.0(@nuxt/kit@3.16.1(magicast@0.3.5))(vite@6.2.3(@types/node@22.13.13)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0)):
dependencies:
ansis: 3.17.0
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
error-stack-parser-es: 1.0.5
ohash: 2.0.11
open: 10.1.0
@@ -23748,7 +23735,7 @@ snapshots:
dependencies:
'@rollup/pluginutils': 4.2.1
chalk: 4.1.2
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
vite: 6.2.3(@types/node@22.13.13)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0)
optionalDependencies:
'@swc/core': 1.11.13(@swc/helpers@0.5.15)
@@ -23801,7 +23788,7 @@ snapshots:
vite-plugin-vuetify@2.1.0(vite@6.2.3(@types/node@22.13.13)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2))(vuetify@3.7.18):
dependencies:
'@vuetify/loader-shared': 2.1.0(vue@3.5.13(typescript@5.8.2))(vuetify@3.7.18)
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
upath: 2.0.1
vite: 6.2.3(@types/node@22.13.13)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0)
vue: 3.5.13(typescript@5.8.2)
@@ -23811,7 +23798,7 @@ snapshots:
vite-tsconfig-paths@5.1.4(typescript@5.8.2)(vite@6.2.3(@types/node@22.13.13)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0)):
dependencies:
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
globrex: 0.1.2
tsconfck: 3.1.5(typescript@5.8.2)
optionalDependencies:
@@ -23886,7 +23873,7 @@ snapshots:
'@vitest/spy': 3.0.9
'@vitest/utils': 3.0.9
chai: 5.2.0
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
expect-type: 1.2.0
magic-string: 0.30.17
pathe: 2.0.3
@@ -23927,7 +23914,7 @@ snapshots:
'@vitest/spy': 3.0.9
'@vitest/utils': 3.0.9
chai: 5.2.0
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
expect-type: 1.2.0
magic-string: 0.30.17
pathe: 2.0.3
@@ -23968,7 +23955,7 @@ snapshots:
'@vitest/spy': 3.1.1
'@vitest/utils': 3.1.1
chai: 5.2.0
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
expect-type: 1.2.0
magic-string: 0.30.17
pathe: 2.0.3
@@ -24050,7 +24037,7 @@ snapshots:
vue-eslint-parser@10.1.1(eslint@9.23.0(jiti@2.4.2)):
dependencies:
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
eslint: 9.23.0(jiti@2.4.2)
eslint-scope: 8.3.0
eslint-visitor-keys: 4.2.0
@@ -24063,7 +24050,7 @@ snapshots:
vue-eslint-parser@9.4.3(eslint@9.23.0(jiti@2.4.2)):
dependencies:
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
eslint: 9.23.0(jiti@2.4.2)
eslint-scope: 7.2.2
eslint-visitor-keys: 3.4.3

View File

@@ -23,16 +23,7 @@ const config: CodegenConfig = {
config: {
useTypeImports: true,
},
schema: [
{
'http://localhost:3001/graphql': {
headers: {
origin: '/var/run/unraid-php.sock',
'x-api-key': 'unupc_fab6ff6ffe51040595c6d9ffb63a353ba16cc2ad7d93f813a2e80a5810',
},
},
},
],
schema: '../api/generated-schema.graphql',
plugins: [
{
add: {

View File

@@ -326,6 +326,21 @@ export enum ArrayStateInputState {
Stop = 'STOP'
}
/** Available authentication action verbs */
export enum AuthActionVerb {
Create = 'CREATE',
Delete = 'DELETE',
Read = 'READ',
Update = 'UPDATE'
}
/** Available authentication possession types */
export enum AuthPossession {
Any = 'ANY',
Own = 'OWN',
OwnAny = 'OWN_ANY'
}
export type Baseboard = {
__typename?: 'Baseboard';
assetTag?: Maybe<Scalars['String']['output']>;
@@ -862,6 +877,16 @@ export type Mutation = {
* Some setting combinations may be required or disallowed. Please refer to each setting for more information.
*/
updateApiSettings: ConnectSettingsValues;
/**
* Virtual machine mutations
*
* #### Required Permissions:
*
* - Action: **READ**
* - Resource: **VMS**
* - Possession: **ANY**
*/
vms?: Maybe<VmMutations>;
};
@@ -1234,7 +1259,15 @@ export type Query = {
/** User accounts */
users: Array<User>;
vars?: Maybe<Vars>;
/** Virtual machines */
/**
* Virtual machines
*
* #### Required Permissions:
*
* - Action: **READ**
* - Resource: **VMS**
* - Possession: **ANY**
*/
vms?: Maybe<Vms>;
};
@@ -1352,41 +1385,41 @@ export type RemoveRoleFromApiKeyInput = {
/** Available resources for permissions */
export enum Resource {
ApiKey = 'api_key',
Array = 'array',
Cloud = 'cloud',
Config = 'config',
Connect = 'connect',
ConnectRemoteAccess = 'connect__remote_access',
Customizations = 'customizations',
Dashboard = 'dashboard',
Disk = 'disk',
Display = 'display',
Docker = 'docker',
Flash = 'flash',
Info = 'info',
Logs = 'logs',
Me = 'me',
Network = 'network',
Notifications = 'notifications',
Online = 'online',
Os = 'os',
Owner = 'owner',
Permission = 'permission',
Registration = 'registration',
Servers = 'servers',
Services = 'services',
Share = 'share',
Vars = 'vars',
Vms = 'vms',
Welcome = 'welcome'
ApiKey = 'API_KEY',
Array = 'ARRAY',
Cloud = 'CLOUD',
Config = 'CONFIG',
Connect = 'CONNECT',
ConnectRemoteAccess = 'CONNECT__REMOTE_ACCESS',
Customizations = 'CUSTOMIZATIONS',
Dashboard = 'DASHBOARD',
Disk = 'DISK',
Display = 'DISPLAY',
Docker = 'DOCKER',
Flash = 'FLASH',
Info = 'INFO',
Logs = 'LOGS',
Me = 'ME',
Network = 'NETWORK',
Notifications = 'NOTIFICATIONS',
Online = 'ONLINE',
Os = 'OS',
Owner = 'OWNER',
Permission = 'PERMISSION',
Registration = 'REGISTRATION',
Servers = 'SERVERS',
Services = 'SERVICES',
Share = 'SHARE',
Vars = 'VARS',
Vms = 'VMS',
Welcome = 'WELCOME'
}
/** Available roles for API keys and users */
export enum Role {
Admin = 'admin',
Connect = 'connect',
Guest = 'guest'
Admin = 'ADMIN',
Connect = 'CONNECT',
Guest = 'GUEST'
}
export type Server = {
@@ -1482,6 +1515,15 @@ export type Subscription = {
user: User;
users: Array<Maybe<User>>;
vars: Vars;
/**
*
*
* #### Required Permissions:
*
* - Action: **READ**
* - Resource: **VMS**
* - Possession: **ANY**
*/
vms?: Maybe<Vms>;
};
@@ -1835,6 +1877,115 @@ export type VmDomain = {
uuid: Scalars['ID']['output'];
};
export type VmMutations = {
__typename?: 'VmMutations';
/**
* Force stop a virtual machine
*
* #### Required Permissions:
*
* - Action: **UPDATE**
* - Resource: **VMS**
* - Possession: **ANY**
*/
forceStopVm: Scalars['Boolean']['output'];
/**
* Pause a virtual machine
*
* #### Required Permissions:
*
* - Action: **UPDATE**
* - Resource: **VMS**
* - Possession: **ANY**
*/
pauseVm: Scalars['Boolean']['output'];
/**
* Reboot a virtual machine
*
* #### Required Permissions:
*
* - Action: **UPDATE**
* - Resource: **VMS**
* - Possession: **ANY**
*/
rebootVm: Scalars['Boolean']['output'];
/**
* Reset a virtual machine
*
* #### Required Permissions:
*
* - Action: **UPDATE**
* - Resource: **VMS**
* - Possession: **ANY**
*/
resetVm: Scalars['Boolean']['output'];
/**
* Resume a virtual machine
*
* #### Required Permissions:
*
* - Action: **UPDATE**
* - Resource: **VMS**
* - Possession: **ANY**
*/
resumeVm: Scalars['Boolean']['output'];
/**
* Start a virtual machine
*
* #### Required Permissions:
*
* - Action: **UPDATE**
* - Resource: **VMS**
* - Possession: **ANY**
*/
startVm: Scalars['Boolean']['output'];
/**
* Stop a virtual machine
*
* #### Required Permissions:
*
* - Action: **UPDATE**
* - Resource: **VMS**
* - Possession: **ANY**
*/
stopVm: Scalars['Boolean']['output'];
};
export type VmMutationsforceStopVmArgs = {
id: Scalars['ID']['input'];
};
export type VmMutationspauseVmArgs = {
id: Scalars['ID']['input'];
};
export type VmMutationsrebootVmArgs = {
id: Scalars['ID']['input'];
};
export type VmMutationsresetVmArgs = {
id: Scalars['ID']['input'];
};
export type VmMutationsresumeVmArgs = {
id: Scalars['ID']['input'];
};
export type VmMutationsstartVmArgs = {
id: Scalars['ID']['input'];
};
export type VmMutationsstopVmArgs = {
id: Scalars['ID']['input'];
};
export enum VmState {
Crashed = 'CRASHED',
Idle = 'IDLE',
@@ -1848,6 +1999,15 @@ export enum VmState {
export type Vms = {
__typename?: 'Vms';
/**
*
*
* #### Required Permissions:
*
* - Action: **READ**
* - Resource: **VMS**
* - Possession: **ANY**
*/
domain?: Maybe<Array<VmDomain>>;
id: Scalars['ID']['output'];
};