mirror of
https://github.com/unraid/api.git
synced 2026-01-06 08:39:54 -06:00
Compare commits
24 Commits
4.13.1-bui
...
4.16.0-bui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb7a26a63d | ||
|
|
16dfdd7082 | ||
|
|
ab11e7ff7f | ||
|
|
7316dc753f | ||
|
|
1bf74e9d6c | ||
|
|
9cd0d6ac65 | ||
|
|
f0348aa038 | ||
|
|
c1ab3a4746 | ||
|
|
7d67a40433 | ||
|
|
674323fd87 | ||
|
|
6947b5d4af | ||
|
|
c4cc54923c | ||
|
|
c508366702 | ||
|
|
9df6a3f5eb | ||
|
|
aa588883cc | ||
|
|
b2e7801238 | ||
|
|
fd895cacf0 | ||
|
|
6edd3a3d16 | ||
|
|
ac198d5d1a | ||
|
|
f1c043fe5f | ||
|
|
d0c66020e1 | ||
|
|
335f949b53 | ||
|
|
26aeca3624 | ||
|
|
2b4c2a264b |
4
.github/workflows/test-libvirt.yml
vendored
4
.github/workflows/test-libvirt.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13.6"
|
||||
python-version: "3.13.7"
|
||||
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.14.0
|
||||
version: 10.15.0
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"4.13.1"}
|
||||
{".":"4.16.0"}
|
||||
|
||||
@@ -156,4 +156,5 @@ Enables GraphQL playground at `http://tower.local/graphql`
|
||||
## Development Memories
|
||||
|
||||
- We are using tailwind v4 we do not need a tailwind config anymore
|
||||
- always search the internet for tailwind v4 documentation when making tailwind related style changes
|
||||
- always search the internet for tailwind v4 documentation when making tailwind related style changes
|
||||
- never run or restart the API server or web server. I will handle the lifecylce, simply wait and ask me to do this for you
|
||||
@@ -18,6 +18,7 @@ PATHS_LOG_BASE=./dev/log # Where we store logs
|
||||
PATHS_LOGS_FILE=./dev/log/graphql-api.log
|
||||
PATHS_CONNECT_STATUS_FILE_PATH=./dev/connectStatus.json # Connect plugin status file
|
||||
PATHS_OIDC_JSON=./dev/configs/oidc.local.json
|
||||
PATHS_LOCAL_SESSION_FILE=./dev/local-session
|
||||
ENVIRONMENT="development"
|
||||
NODE_ENV="development"
|
||||
PORT="3001"
|
||||
|
||||
@@ -14,5 +14,6 @@ PATHS_CONFIG_MODULES=./dev/configs
|
||||
PATHS_ACTIVATION_BASE=./dev/activation
|
||||
PATHS_PASSWD=./dev/passwd
|
||||
PATHS_LOGS_FILE=./dev/log/graphql-api.log
|
||||
PATHS_LOCAL_SESSION_FILE=./dev/local-session
|
||||
PORT=5000
|
||||
NODE_ENV="test"
|
||||
|
||||
2
api/.gitignore
vendored
2
api/.gitignore
vendored
@@ -88,6 +88,8 @@ dev/connectStatus.json
|
||||
dev/configs/*
|
||||
# local status - doesn't need to be tracked
|
||||
dev/connectStatus.json
|
||||
# mock local session file
|
||||
dev/local-session
|
||||
|
||||
# local OIDC config for testing - contains secrets
|
||||
dev/configs/oidc.local.json
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
# Changelog
|
||||
|
||||
## [4.16.0](https://github.com/unraid/api/compare/v4.15.1...v4.16.0) (2025-08-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add `parityCheckStatus` field to `array` query ([#1611](https://github.com/unraid/api/issues/1611)) ([c508366](https://github.com/unraid/api/commit/c508366702b9fa20d9ed05559fe73da282116aa6))
|
||||
* generated UI API key management + OAuth-like API Key Flows ([#1609](https://github.com/unraid/api/issues/1609)) ([674323f](https://github.com/unraid/api/commit/674323fd87bbcc55932e6b28f6433a2de79b7ab0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **connect:** clear `wanport` upon disabling remote access ([#1624](https://github.com/unraid/api/issues/1624)) ([9df6a3f](https://github.com/unraid/api/commit/9df6a3f5ebb0319aa7e3fe3be6159d39ec6f587f))
|
||||
* **connect:** valid LAN FQDN while remote access is enabled ([#1625](https://github.com/unraid/api/issues/1625)) ([aa58888](https://github.com/unraid/api/commit/aa588883cc2e2fe4aa4aea1d035236c888638f5b))
|
||||
* correctly parse periods in share names from ini file ([#1629](https://github.com/unraid/api/issues/1629)) ([7d67a40](https://github.com/unraid/api/commit/7d67a404333a38d6e1ba5c3febf02be8b1b71901))
|
||||
* **rc.unraid-api:** remove profile sourcing ([#1622](https://github.com/unraid/api/issues/1622)) ([6947b5d](https://github.com/unraid/api/commit/6947b5d4aff70319116eb65cf4c639444f3749e9))
|
||||
* remove unused api key calls ([#1628](https://github.com/unraid/api/issues/1628)) ([9cd0d6a](https://github.com/unraid/api/commit/9cd0d6ac658475efa25683ef6e3f2e1d68f7e903))
|
||||
* retry VMs init for up to 2 min ([#1612](https://github.com/unraid/api/issues/1612)) ([b2e7801](https://github.com/unraid/api/commit/b2e78012384e6b3f2630341281fc811026be23b9))
|
||||
|
||||
## [4.15.1](https://github.com/unraid/api/compare/v4.15.0...v4.15.1) (2025-08-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* minor duplicate click handler and version resolver nullability issue ([ac198d5](https://github.com/unraid/api/commit/ac198d5d1a3073fdeb053c2ff8f704b0dba0d047))
|
||||
|
||||
## [4.15.0](https://github.com/unraid/api/compare/v4.14.0...v4.15.0) (2025-08-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** restructure versioning information in GraphQL schema ([#1600](https://github.com/unraid/api/issues/1600)) ([d0c6602](https://github.com/unraid/api/commit/d0c66020e1d1d5b6fcbc4ee8979bba4b3d34c7ad))
|
||||
|
||||
## [4.14.0](https://github.com/unraid/api/compare/v4.13.1...v4.14.0) (2025-08-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** add cpu utilization query and subscription ([#1590](https://github.com/unraid/api/issues/1590)) ([2b4c2a2](https://github.com/unraid/api/commit/2b4c2a264bb2769f88c3000d16447889cae57e98))
|
||||
* enhance OIDC claim evaluation with array handling ([#1596](https://github.com/unraid/api/issues/1596)) ([b7798b8](https://github.com/unraid/api/commit/b7798b82f44aae9a428261270fd9dbde35ff7751))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove unraid-api sso users & always apply sso modification on < 7.2 ([#1595](https://github.com/unraid/api/issues/1595)) ([4262830](https://github.com/unraid/api/commit/426283011afd41e3af7e48cfbb2a2d351c014bd1))
|
||||
* update Docusaurus PR workflow to process and copy API docs ([3a10871](https://github.com/unraid/api/commit/3a10871918fe392a1974b69d16a135546166e058))
|
||||
* update OIDC provider setup documentation for navigation clarity ([1a01696](https://github.com/unraid/api/commit/1a01696dc7b947abf5f2f097de1b231d5593c2ff))
|
||||
* update OIDC provider setup documentation for redirect URI and screenshots ([1bc5251](https://github.com/unraid/api/commit/1bc52513109436b3ce8237c3796af765e208f9fc))
|
||||
|
||||
## [4.13.1](https://github.com/unraid/api/compare/v4.13.0...v4.13.1) (2025-08-15)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.12.0",
|
||||
"version": "4.15.1",
|
||||
"extraOrigins": [],
|
||||
"sandbox": true,
|
||||
"ssoSubIds": [],
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"createdAt": "2025-01-27T16:22:56.501Z",
|
||||
"description": "API key for Connect user",
|
||||
"id": "b5b4aa3d-8e40-4c92-bc40-d50182071886",
|
||||
"key": "_______________________LOCAL_API_KEY_HERE_________________________",
|
||||
"name": "Connect",
|
||||
"permissions": [],
|
||||
"roles": [
|
||||
"CONNECT"
|
||||
]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"createdAt": "2025-07-23T17:34:06.301Z",
|
||||
"description": "Internal admin API key used by CLI commands for system operations",
|
||||
"id": "fc91da7b-0284-46f4-9018-55aa9759fba9",
|
||||
"key": "_______SUPER_SECRET_KEY_______",
|
||||
"name": "CliInternal",
|
||||
"permissions": [],
|
||||
"roles": [
|
||||
"ADMIN"
|
||||
]
|
||||
}
|
||||
@@ -65,4 +65,38 @@ color="yellow-on"
|
||||
size="0"
|
||||
free="9091184"
|
||||
used="32831348"
|
||||
luksStatus="0"
|
||||
["system.with.periods"]
|
||||
name="system.with.periods"
|
||||
nameOrig="system.with.periods"
|
||||
comment="system data with periods"
|
||||
allocator="highwater"
|
||||
splitLevel="1"
|
||||
floor="0"
|
||||
include=""
|
||||
exclude=""
|
||||
useCache="prefer"
|
||||
cachePool="cache"
|
||||
cow="auto"
|
||||
color="yellow-on"
|
||||
size="0"
|
||||
free="9091184"
|
||||
used="32831348"
|
||||
luksStatus="0"
|
||||
["system.with.🚀"]
|
||||
name="system.with.🚀"
|
||||
nameOrig="system.with.🚀"
|
||||
comment="system data with 🚀"
|
||||
allocator="highwater"
|
||||
splitLevel="1"
|
||||
floor="0"
|
||||
include=""
|
||||
exclude=""
|
||||
useCache="prefer"
|
||||
cachePool="cache"
|
||||
cow="auto"
|
||||
color="yellow-on"
|
||||
size="0"
|
||||
free="9091184"
|
||||
used="32831348"
|
||||
luksStatus="0"
|
||||
100
api/docs/public/api-key-app-developer-authorization-flow.md
Normal file
100
api/docs/public/api-key-app-developer-authorization-flow.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# API Key Authorization Flow
|
||||
|
||||
This document describes the self-service API key creation flow for third-party applications.
|
||||
|
||||
## Overview
|
||||
|
||||
Applications can request API access to an Unraid server by redirecting users to a special authorization page where users can review requested permissions and create an API key with one click.
|
||||
|
||||
## Flow
|
||||
|
||||
1. **Application initiates request**: The app redirects the user to:
|
||||
|
||||
```
|
||||
https://[unraid-server]/ApiKeyAuthorize?name=MyApp&scopes=docker:read,vm:*&redirect_uri=https://myapp.com/callback&state=abc123
|
||||
```
|
||||
|
||||
2. **User authentication**: If not already logged in, the user is redirected to login first (standard Unraid auth)
|
||||
|
||||
3. **Consent screen**: User sees:
|
||||
- Application name and description
|
||||
- Requested permissions (with checkboxes to approve/deny specific scopes)
|
||||
- API key name field (pre-filled)
|
||||
- Authorize & Cancel buttons
|
||||
|
||||
4. **API key creation**: Upon authorization:
|
||||
- API key is created with approved scopes
|
||||
- Key is displayed to the user
|
||||
- If `redirect_uri` is provided, user is redirected back with the key
|
||||
|
||||
5. **Callback**: App receives the API key:
|
||||
```
|
||||
https://myapp.com/callback?api_key=xxx&state=abc123
|
||||
```
|
||||
|
||||
## Query Parameters
|
||||
|
||||
- `name` (required): Name of the requesting application
|
||||
- `description` (optional): Description of the application
|
||||
- `scopes` (required): Comma-separated list of requested scopes
|
||||
- `redirect_uri` (optional): URL to redirect after authorization
|
||||
- `state` (optional): Opaque value for maintaining state
|
||||
|
||||
## Scope Format
|
||||
|
||||
Scopes follow the pattern: `resource:action`
|
||||
|
||||
### Examples:
|
||||
|
||||
- `docker:read` - Read access to Docker
|
||||
- `vm:*` - Full access to VMs
|
||||
- `system:update` - Update access to system
|
||||
- `role:viewer` - Viewer role access
|
||||
- `role:admin` - Admin role access
|
||||
|
||||
### Available Resources:
|
||||
|
||||
- `docker`, `vm`, `system`, `share`, `user`, `network`, `disk`, etc.
|
||||
|
||||
### Available Actions:
|
||||
|
||||
- `create`, `read`, `update`, `delete` or `*` for all
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **HTTPS required**: Redirect URIs must use HTTPS (except localhost for development)
|
||||
2. **User consent**: Users explicitly approve each permission
|
||||
3. **Session-based**: Uses existing Unraid authentication session
|
||||
4. **One-time display**: API keys are shown once and must be saved securely
|
||||
|
||||
## Example Integration
|
||||
|
||||
```javascript
|
||||
// JavaScript example
|
||||
const unraidServer = 'tower.local';
|
||||
const appName = 'My Docker Manager';
|
||||
const scopes = 'docker:*,system:read';
|
||||
const redirectUri = 'https://myapp.com/unraid/callback';
|
||||
const state = generateRandomState();
|
||||
|
||||
// Store state for verification
|
||||
sessionStorage.setItem('oauth_state', state);
|
||||
|
||||
// Redirect user to authorization page
|
||||
window.location.href =
|
||||
`https://${unraidServer}/ApiKeyAuthorize?` +
|
||||
`name=${encodeURIComponent(appName)}&` +
|
||||
`scopes=${encodeURIComponent(scopes)}&` +
|
||||
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
|
||||
`state=${encodeURIComponent(state)}`;
|
||||
|
||||
// Handle callback
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const apiKey = urlParams.get('api_key');
|
||||
const returnedState = urlParams.get('state');
|
||||
|
||||
if (returnedState === sessionStorage.getItem('oauth_state')) {
|
||||
// Save API key securely
|
||||
saveApiKey(apiKey);
|
||||
}
|
||||
```
|
||||
@@ -7,32 +7,34 @@ sidebar_position: 1
|
||||
# Welcome to Unraid API
|
||||
|
||||
:::tip[What's New]
|
||||
Native integration in Unraid v7.2+ brings the API directly into the OS - no plugin needed!
|
||||
Starting with Unraid OS v7.2, the API comes built into the operating system - no plugin installation required!
|
||||
:::
|
||||
|
||||
The Unraid API provides a GraphQL interface for programmatic interaction with your Unraid server. It enables automation, monitoring, and integration capabilities.
|
||||
|
||||
## 📦 Availability
|
||||
|
||||
### ✨ Native Integration (Unraid v7.2-beta.1+)
|
||||
### ✨ Native Integration (Unraid OS v7.2+)
|
||||
|
||||
Starting with Unraid v7.2-beta.1, the API is integrated directly into the Unraid operating system:
|
||||
Starting with Unraid OS v7.2, the API is integrated directly into the operating system:
|
||||
|
||||
- No plugin installation required
|
||||
- Automatically available on system startup
|
||||
- Deep system integration
|
||||
- Access through **Settings** → **Management Access** → **API**
|
||||
|
||||
### 🔌 Plugin Installation (Earlier Versions)
|
||||
### 🔌 Plugin Installation (Pre-7.2 and Advanced Users)
|
||||
|
||||
For Unraid versions prior to v7.2:
|
||||
For Unraid versions prior to v7.2 or to access newer API features:
|
||||
|
||||
1. Install Unraid Connect Plugin from Apps
|
||||
1. Install the Unraid Connect Plugin from Community Apps
|
||||
2. [Configure the plugin](./how-to-use-the-api.md#enabling-the-graphql-sandbox)
|
||||
3. Access API functionality through the [GraphQL Sandbox](./how-to-use-the-api.md)
|
||||
|
||||
:::tip Pre-release Versions
|
||||
You can install the Unraid Connect plugin on any version to access pre-release versions of the API and get early access to new features before they're included in Unraid OS releases.
|
||||
:::info Important Notes
|
||||
- The Unraid Connect plugin provides the API for pre-7.2 versions
|
||||
- You do NOT need to sign in to Unraid Connect to use the API locally
|
||||
- Installing the plugin on 7.2+ gives you access to newer API features before they're included in OS releases
|
||||
:::
|
||||
|
||||
## 📚 Documentation Sections
|
||||
@@ -69,20 +71,22 @@ The API provides:
|
||||
## 🚀 Get Started
|
||||
|
||||
<tabs>
|
||||
<tabItem value="v72" label="Unraid v7.2+" default>
|
||||
<tabItem value="v72" label="Unraid OS v7.2+" default>
|
||||
|
||||
1. Access the API settings at **Settings** → **Management Access** → **API**
|
||||
2. Enable the GraphQL Sandbox for development
|
||||
3. Create your first API key
|
||||
4. Start making GraphQL queries!
|
||||
1. The API is already installed and running
|
||||
2. Access settings at **Settings** → **Management Access** → **API**
|
||||
3. Enable the GraphQL Sandbox for development
|
||||
4. Create your first API key
|
||||
5. Start making GraphQL queries!
|
||||
|
||||
</tabItem>
|
||||
<tabItem value="older" label="Earlier Versions">
|
||||
<tabItem value="older" label="Pre-7.2 Versions">
|
||||
|
||||
1. Install the Unraid Connect plugin from Apps
|
||||
2. Configure the plugin settings
|
||||
3. Enable the GraphQL Sandbox
|
||||
4. Start exploring the API!
|
||||
1. Install the Unraid Connect plugin from Community Apps
|
||||
2. No Unraid Connect login required for local API access
|
||||
3. Configure the plugin settings
|
||||
4. Enable the GraphQL Sandbox
|
||||
5. Start exploring the API!
|
||||
|
||||
</tabItem>
|
||||
</tabs>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/api",
|
||||
"version": "4.13.1",
|
||||
"version": "4.16.0",
|
||||
"main": "src/cli/index.ts",
|
||||
"type": "module",
|
||||
"corepack": {
|
||||
@@ -10,7 +10,7 @@
|
||||
"author": "Lime Technology, Inc. <unraid.net>",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"engines": {
|
||||
"pnpm": "10.14.0"
|
||||
"pnpm": "10.15.0"
|
||||
},
|
||||
"scripts": {
|
||||
"// Development": "",
|
||||
@@ -51,7 +51,7 @@
|
||||
"unraid-api": "dist/cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "3.13.9",
|
||||
"@apollo/client": "3.14.0",
|
||||
"@apollo/server": "4.12.2",
|
||||
"@as-integrations/fastify": "2.1.1",
|
||||
"@fastify/cookie": "11.0.2",
|
||||
@@ -82,7 +82,7 @@
|
||||
"atomically": "2.0.3",
|
||||
"bycontract": "2.0.11",
|
||||
"bytes": "3.1.2",
|
||||
"cache-manager": "7.1.1",
|
||||
"cache-manager": "7.2.0",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"camelcase-keys": "9.1.3",
|
||||
"casbin": "5.38.0",
|
||||
@@ -115,22 +115,22 @@
|
||||
"graphql-ws": "6.0.6",
|
||||
"ini": "5.0.0",
|
||||
"ip": "2.0.1",
|
||||
"jose": "6.0.12",
|
||||
"jose": "6.0.13",
|
||||
"json-bigint-patch": "0.0.8",
|
||||
"lodash-es": "4.17.21",
|
||||
"multi-ini": "2.3.2",
|
||||
"mustache": "4.2.0",
|
||||
"nest-authz": "2.17.0",
|
||||
"nest-commander": "3.18.0",
|
||||
"nest-commander": "3.19.0",
|
||||
"nestjs-pino": "4.4.0",
|
||||
"node-cache": "5.1.2",
|
||||
"node-window-polyfill": "1.0.4",
|
||||
"openid-client": "6.6.2",
|
||||
"openid-client": "6.6.4",
|
||||
"p-retry": "6.2.1",
|
||||
"passport-custom": "1.1.1",
|
||||
"passport-http-header-strategy": "1.1.0",
|
||||
"path-type": "6.0.0",
|
||||
"pino": "9.8.0",
|
||||
"pino": "9.9.0",
|
||||
"pino-http": "10.5.0",
|
||||
"pino-pretty": "13.1.1",
|
||||
"pm2": "6.0.8",
|
||||
@@ -138,8 +138,8 @@
|
||||
"rxjs": "7.8.2",
|
||||
"semver": "7.7.2",
|
||||
"strftime": "0.10.3",
|
||||
"systeminformation": "5.27.7",
|
||||
"undici": "7.13.0",
|
||||
"systeminformation": "5.27.8",
|
||||
"undici": "7.15.0",
|
||||
"uuid": "11.1.0",
|
||||
"ws": "8.18.3",
|
||||
"zen-observable-ts": "1.1.0",
|
||||
@@ -154,7 +154,7 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.33.0",
|
||||
"@eslint/js": "9.34.0",
|
||||
"@graphql-codegen/add": "5.0.3",
|
||||
"@graphql-codegen/cli": "5.0.7",
|
||||
"@graphql-codegen/fragment-matcher": "5.1.0",
|
||||
@@ -164,17 +164,17 @@
|
||||
"@graphql-codegen/typescript-operations": "4.6.1",
|
||||
"@graphql-codegen/typescript-resolvers": "4.5.1",
|
||||
"@graphql-typed-document-node/core": "3.2.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "4.6.1",
|
||||
"@ianvs/prettier-plugin-sort-imports": "4.6.3",
|
||||
"@nestjs/testing": "11.1.6",
|
||||
"@originjs/vite-plugin-commonjs": "1.0.3",
|
||||
"@rollup/plugin-node-resolve": "16.0.1",
|
||||
"@swc/core": "1.13.3",
|
||||
"@swc/core": "1.13.5",
|
||||
"@types/async-exit-hook": "2.0.2",
|
||||
"@types/bytes": "3.1.5",
|
||||
"@types/cli-table": "0.3.4",
|
||||
"@types/command-exists": "1.2.3",
|
||||
"@types/cors": "2.8.19",
|
||||
"@types/dockerode": "3.3.42",
|
||||
"@types/dockerode": "3.3.43",
|
||||
"@types/graphql-fields": "1.3.9",
|
||||
"@types/graphql-type-uuid": "0.2.6",
|
||||
"@types/ini": "4.1.1",
|
||||
@@ -182,7 +182,7 @@
|
||||
"@types/lodash": "4.17.20",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/mustache": "4.2.6",
|
||||
"@types/node": "22.17.1",
|
||||
"@types/node": "22.18.0",
|
||||
"@types/pify": "6.1.0",
|
||||
"@types/semver": "7.7.0",
|
||||
"@types/sendmail": "1.4.7",
|
||||
@@ -191,28 +191,28 @@
|
||||
"@types/supertest": "6.0.3",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/wtfnode": "0.7.3",
|
||||
"@types/wtfnode": "0.10.0",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"eslint": "9.33.0",
|
||||
"eslint": "9.34.0",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-no-relative-import-paths": "1.6.1",
|
||||
"eslint-plugin-prettier": "5.5.4",
|
||||
"jiti": "2.5.1",
|
||||
"nodemon": "3.1.10",
|
||||
"prettier": "3.6.2",
|
||||
"rollup-plugin-node-externals": "8.0.1",
|
||||
"rollup-plugin-node-externals": "8.1.0",
|
||||
"supertest": "7.1.4",
|
||||
"tsx": "4.20.3",
|
||||
"tsx": "4.20.5",
|
||||
"type-fest": "4.41.0",
|
||||
"typescript": "5.9.2",
|
||||
"typescript-eslint": "8.39.1",
|
||||
"unplugin-swc": "1.5.5",
|
||||
"vite": "7.1.1",
|
||||
"typescript-eslint": "8.41.0",
|
||||
"unplugin-swc": "1.5.7",
|
||||
"vite": "7.1.3",
|
||||
"vite-plugin-node": "7.0.0",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "3.2.4",
|
||||
"zx": "8.8.0"
|
||||
"zx": "8.8.1"
|
||||
},
|
||||
"overrides": {
|
||||
"eslint": {
|
||||
@@ -227,5 +227,5 @@
|
||||
}
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.14.0"
|
||||
"packageManager": "pnpm@10.15.0"
|
||||
}
|
||||
|
||||
@@ -95,6 +95,48 @@ test('Returns both disk and user shares', async () => {
|
||||
"type": "user",
|
||||
"used": 33619300,
|
||||
},
|
||||
{
|
||||
"allocator": "highwater",
|
||||
"cachePool": "cache",
|
||||
"color": "yellow-on",
|
||||
"comment": "system data with periods",
|
||||
"cow": "auto",
|
||||
"exclude": [],
|
||||
"floor": "0",
|
||||
"free": 9309372,
|
||||
"id": "system.with.periods",
|
||||
"include": [],
|
||||
"luksStatus": "0",
|
||||
"name": "system.with.periods",
|
||||
"nameOrig": "system.with.periods",
|
||||
"nfs": {},
|
||||
"size": 0,
|
||||
"smb": {},
|
||||
"splitLevel": "1",
|
||||
"type": "user",
|
||||
"used": 33619300,
|
||||
},
|
||||
{
|
||||
"allocator": "highwater",
|
||||
"cachePool": "cache",
|
||||
"color": "yellow-on",
|
||||
"comment": "system data with 🚀",
|
||||
"cow": "auto",
|
||||
"exclude": [],
|
||||
"floor": "0",
|
||||
"free": 9309372,
|
||||
"id": "system.with.🚀",
|
||||
"include": [],
|
||||
"luksStatus": "0",
|
||||
"name": "system.with.🚀",
|
||||
"nameOrig": "system.with.🚀",
|
||||
"nfs": {},
|
||||
"size": 0,
|
||||
"smb": {},
|
||||
"splitLevel": "1",
|
||||
"type": "user",
|
||||
"used": 33619300,
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
@@ -211,6 +253,48 @@ test('Returns shares by type', async () => {
|
||||
"type": "user",
|
||||
"used": 33619300,
|
||||
},
|
||||
{
|
||||
"allocator": "highwater",
|
||||
"cachePool": "cache",
|
||||
"color": "yellow-on",
|
||||
"comment": "system data with periods",
|
||||
"cow": "auto",
|
||||
"exclude": [],
|
||||
"floor": "0",
|
||||
"free": 9309372,
|
||||
"id": "system.with.periods",
|
||||
"include": [],
|
||||
"luksStatus": "0",
|
||||
"name": "system.with.periods",
|
||||
"nameOrig": "system.with.periods",
|
||||
"nfs": {},
|
||||
"size": 0,
|
||||
"smb": {},
|
||||
"splitLevel": "1",
|
||||
"type": "user",
|
||||
"used": 33619300,
|
||||
},
|
||||
{
|
||||
"allocator": "highwater",
|
||||
"cachePool": "cache",
|
||||
"color": "yellow-on",
|
||||
"comment": "system data with 🚀",
|
||||
"cow": "auto",
|
||||
"exclude": [],
|
||||
"floor": "0",
|
||||
"free": 9309372,
|
||||
"id": "system.with.🚀",
|
||||
"include": [],
|
||||
"luksStatus": "0",
|
||||
"name": "system.with.🚀",
|
||||
"nameOrig": "system.with.🚀",
|
||||
"nfs": {},
|
||||
"size": 0,
|
||||
"smb": {},
|
||||
"splitLevel": "1",
|
||||
"type": "user",
|
||||
"used": 33619300,
|
||||
},
|
||||
]
|
||||
`);
|
||||
expect(getShares('disk')).toMatchInlineSnapshot('null');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { parseConfig } from '@app/core/utils/misc/parse-config.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { FileLoadStatus } from '@app/store/types.js';
|
||||
|
||||
@@ -446,6 +447,44 @@ test('After init returns values from cfg file for all fields', { timeout: 30000
|
||||
"splitLevel": "1",
|
||||
"used": 33619300,
|
||||
},
|
||||
{
|
||||
"allocator": "highwater",
|
||||
"cache": false,
|
||||
"cachePool": "cache",
|
||||
"color": "yellow-on",
|
||||
"comment": "system data with periods",
|
||||
"cow": "auto",
|
||||
"exclude": [],
|
||||
"floor": "0",
|
||||
"free": 9309372,
|
||||
"id": "system.with.periods",
|
||||
"include": [],
|
||||
"luksStatus": "0",
|
||||
"name": "system.with.periods",
|
||||
"nameOrig": "system.with.periods",
|
||||
"size": 0,
|
||||
"splitLevel": "1",
|
||||
"used": 33619300,
|
||||
},
|
||||
{
|
||||
"allocator": "highwater",
|
||||
"cache": false,
|
||||
"cachePool": "cache",
|
||||
"color": "yellow-on",
|
||||
"comment": "system data with 🚀",
|
||||
"cow": "auto",
|
||||
"exclude": [],
|
||||
"floor": "0",
|
||||
"free": 9309372,
|
||||
"id": "system.with.🚀",
|
||||
"include": [],
|
||||
"luksStatus": "0",
|
||||
"name": "system.with.🚀",
|
||||
"nameOrig": "system.with.🚀",
|
||||
"size": 0,
|
||||
"splitLevel": "1",
|
||||
"used": 33619300,
|
||||
},
|
||||
]
|
||||
`);
|
||||
expect(nfsShares).toMatchInlineSnapshot(`
|
||||
@@ -1110,3 +1149,209 @@ test('After init returns values from cfg file for all fields', { timeout: 30000
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
describe('Share parsing with periods in names', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('parseConfig handles periods in INI section names', () => {
|
||||
const mockIniContent = `
|
||||
["share.with.periods"]
|
||||
name=share.with.periods
|
||||
useCache=yes
|
||||
include=
|
||||
exclude=
|
||||
|
||||
[normal_share]
|
||||
name=normal_share
|
||||
useCache=no
|
||||
include=
|
||||
exclude=
|
||||
`;
|
||||
|
||||
const result = parseConfig<any>({
|
||||
file: mockIniContent,
|
||||
type: 'ini',
|
||||
});
|
||||
|
||||
// The result should now have properly flattened keys
|
||||
|
||||
expect(result).toHaveProperty('shareWithPeriods');
|
||||
expect(result).toHaveProperty('normalShare');
|
||||
expect(result.shareWithPeriods.name).toBe('share.with.periods');
|
||||
expect(result.normalShare.name).toBe('normal_share');
|
||||
});
|
||||
|
||||
test('shares parser handles periods in share names correctly', async () => {
|
||||
const { parse } = await import('@app/store/state-parsers/shares.js');
|
||||
|
||||
// The parser expects an object where values are share configs
|
||||
const mockSharesState = {
|
||||
shareWithPeriods: {
|
||||
name: 'share.with.periods',
|
||||
free: '1000000',
|
||||
used: '500000',
|
||||
size: '1500000',
|
||||
include: '',
|
||||
exclude: '',
|
||||
useCache: 'yes',
|
||||
},
|
||||
normalShare: {
|
||||
name: 'normal_share',
|
||||
free: '2000000',
|
||||
used: '750000',
|
||||
size: '2750000',
|
||||
include: '',
|
||||
exclude: '',
|
||||
useCache: 'no',
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = parse(mockSharesState);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
const periodShare = result.find((s) => s.name === 'share.with.periods');
|
||||
const normalShare = result.find((s) => s.name === 'normal_share');
|
||||
|
||||
expect(periodShare).toBeDefined();
|
||||
expect(periodShare?.id).toBe('share.with.periods');
|
||||
expect(periodShare?.name).toBe('share.with.periods');
|
||||
expect(periodShare?.cache).toBe(true);
|
||||
|
||||
expect(normalShare).toBeDefined();
|
||||
expect(normalShare?.id).toBe('normal_share');
|
||||
expect(normalShare?.name).toBe('normal_share');
|
||||
expect(normalShare?.cache).toBe(false);
|
||||
});
|
||||
|
||||
test('SMB parser handles periods in share names', async () => {
|
||||
const { parse } = await import('@app/store/state-parsers/smb.js');
|
||||
|
||||
const mockSmbState = {
|
||||
'share.with.periods': {
|
||||
export: 'e',
|
||||
security: 'public',
|
||||
writeList: '',
|
||||
readList: '',
|
||||
volsizelimit: '0',
|
||||
},
|
||||
normal_share: {
|
||||
export: 'e',
|
||||
security: 'private',
|
||||
writeList: 'user1,user2',
|
||||
readList: '',
|
||||
volsizelimit: '1000',
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = parse(mockSmbState);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
const periodShare = result.find((s) => s.name === 'share.with.periods');
|
||||
const normalShare = result.find((s) => s.name === 'normal_share');
|
||||
|
||||
expect(periodShare).toBeDefined();
|
||||
expect(periodShare?.name).toBe('share.with.periods');
|
||||
expect(periodShare?.enabled).toBe(true);
|
||||
|
||||
expect(normalShare).toBeDefined();
|
||||
expect(normalShare?.name).toBe('normal_share');
|
||||
expect(normalShare?.writeList).toEqual(['user1', 'user2']);
|
||||
});
|
||||
|
||||
test('NFS parser handles periods in share names', async () => {
|
||||
const { parse } = await import('@app/store/state-parsers/nfs.js');
|
||||
|
||||
const mockNfsState = {
|
||||
'share.with.periods': {
|
||||
export: 'e',
|
||||
security: 'public',
|
||||
writeList: '',
|
||||
readList: 'user1',
|
||||
hostList: '',
|
||||
},
|
||||
normal_share: {
|
||||
export: 'd',
|
||||
security: 'private',
|
||||
writeList: 'user2',
|
||||
readList: '',
|
||||
hostList: '192.168.1.0/24',
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = parse(mockNfsState);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
const periodShare = result.find((s) => s.name === 'share.with.periods');
|
||||
const normalShare = result.find((s) => s.name === 'normal_share');
|
||||
|
||||
expect(periodShare).toBeDefined();
|
||||
expect(periodShare?.name).toBe('share.with.periods');
|
||||
expect(periodShare?.enabled).toBe(true);
|
||||
expect(periodShare?.readList).toEqual(['user1']);
|
||||
|
||||
expect(normalShare).toBeDefined();
|
||||
expect(normalShare?.name).toBe('normal_share');
|
||||
expect(normalShare?.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Share lookup with periods in names', () => {
|
||||
test('getShares finds user shares with periods in names', async () => {
|
||||
// Mock the store state
|
||||
const mockStore = await import('@app/store/index.js');
|
||||
const mockEmhttpState = {
|
||||
shares: [
|
||||
{
|
||||
id: 'share.with.periods',
|
||||
name: 'share.with.periods',
|
||||
cache: true,
|
||||
free: 1000000,
|
||||
used: 500000,
|
||||
size: 1500000,
|
||||
include: [],
|
||||
exclude: [],
|
||||
},
|
||||
{
|
||||
id: 'normal_share',
|
||||
name: 'normal_share',
|
||||
cache: false,
|
||||
free: 2000000,
|
||||
used: 750000,
|
||||
size: 2750000,
|
||||
include: [],
|
||||
exclude: [],
|
||||
},
|
||||
],
|
||||
smbShares: [
|
||||
{ name: 'share.with.periods', enabled: true, security: 'public' },
|
||||
{ name: 'normal_share', enabled: true, security: 'private' },
|
||||
],
|
||||
nfsShares: [
|
||||
{ name: 'share.with.periods', enabled: false },
|
||||
{ name: 'normal_share', enabled: true },
|
||||
],
|
||||
disks: [],
|
||||
};
|
||||
|
||||
const gettersSpy = vi.spyOn(mockStore, 'getters', 'get').mockReturnValue({
|
||||
emhttp: () => mockEmhttpState,
|
||||
} as any);
|
||||
|
||||
const { getShares } = await import('@app/core/utils/shares/get-shares.js');
|
||||
|
||||
const periodShare = getShares('user', { name: 'share.with.periods' });
|
||||
const normalShare = getShares('user', { name: 'normal_share' });
|
||||
|
||||
expect(periodShare).not.toBeNull();
|
||||
expect(periodShare?.name).toBe('share.with.periods');
|
||||
expect(periodShare?.type).toBe('user');
|
||||
|
||||
expect(normalShare).not.toBeNull();
|
||||
expect(normalShare?.name).toBe('normal_share');
|
||||
expect(normalShare?.type).toBe('user');
|
||||
|
||||
gettersSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,6 +92,44 @@ test('Returns parsed state file', async () => {
|
||||
"splitLevel": "1",
|
||||
"used": 33619300,
|
||||
},
|
||||
{
|
||||
"allocator": "highwater",
|
||||
"cache": false,
|
||||
"cachePool": "cache",
|
||||
"color": "yellow-on",
|
||||
"comment": "system data with periods",
|
||||
"cow": "auto",
|
||||
"exclude": [],
|
||||
"floor": "0",
|
||||
"free": 9309372,
|
||||
"id": "system.with.periods",
|
||||
"include": [],
|
||||
"luksStatus": "0",
|
||||
"name": "system.with.periods",
|
||||
"nameOrig": "system.with.periods",
|
||||
"size": 0,
|
||||
"splitLevel": "1",
|
||||
"used": 33619300,
|
||||
},
|
||||
{
|
||||
"allocator": "highwater",
|
||||
"cache": false,
|
||||
"cachePool": "cache",
|
||||
"color": "yellow-on",
|
||||
"comment": "system data with 🚀",
|
||||
"cow": "auto",
|
||||
"exclude": [],
|
||||
"floor": "0",
|
||||
"free": 9309372,
|
||||
"id": "system.with.🚀",
|
||||
"include": [],
|
||||
"luksStatus": "0",
|
||||
"name": "system.with.🚀",
|
||||
"nameOrig": "system.with.🚀",
|
||||
"size": 0,
|
||||
"splitLevel": "1",
|
||||
"used": 33619300,
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { sum } from 'lodash-es';
|
||||
|
||||
import { getParityCheckStatus } from '@app/core/modules/array/parity-check-status.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { FileLoadStatus } from '@app/store/types.js';
|
||||
import {
|
||||
@@ -61,5 +62,6 @@ export const getArrayData = (getState = store.getState): UnraidArray => {
|
||||
parities,
|
||||
disks,
|
||||
caches,
|
||||
parityCheckStatus: getParityCheckStatus(emhttp.var),
|
||||
};
|
||||
};
|
||||
|
||||
1080
api/src/core/modules/array/parity-check-status.test.ts
Normal file
1080
api/src/core/modules/array/parity-check-status.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
72
api/src/core/modules/array/parity-check-status.ts
Normal file
72
api/src/core/modules/array/parity-check-status.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { toNumberAlways } from '@unraid/shared/util/data.js';
|
||||
|
||||
import type { Var } from '@app/core/types/states/var.js';
|
||||
import type { ParityCheck } from '@app/unraid-api/graph/resolvers/array/parity.model.js';
|
||||
|
||||
export enum ParityCheckStatus {
|
||||
NEVER_RUN = 'never_run',
|
||||
RUNNING = 'running',
|
||||
PAUSED = 'paused',
|
||||
COMPLETED = 'completed',
|
||||
CANCELLED = 'cancelled',
|
||||
FAILED = 'failed',
|
||||
}
|
||||
|
||||
function calculateParitySpeed(deltaTime: number, deltaBlocks: number) {
|
||||
if (deltaTime === 0 || deltaBlocks === 0) return 0;
|
||||
const deltaBytes = deltaBlocks * 1024;
|
||||
const speedMBps = deltaBytes / deltaTime / 1024 / 1024;
|
||||
return Math.round(speedMBps);
|
||||
}
|
||||
|
||||
type RelevantVarData = Pick<
|
||||
Var,
|
||||
| 'mdResyncPos'
|
||||
| 'mdResyncDt'
|
||||
| 'sbSyncExit'
|
||||
| 'sbSynced'
|
||||
| 'sbSynced2'
|
||||
| 'mdResyncDb'
|
||||
| 'mdResyncSize'
|
||||
>;
|
||||
|
||||
function getStatusFromVarData(varData: RelevantVarData): ParityCheckStatus {
|
||||
const { mdResyncPos, mdResyncDt, sbSyncExit, sbSynced, sbSynced2 } = varData;
|
||||
const mdResyncDtNumber = toNumberAlways(mdResyncDt, 0);
|
||||
const sbSyncExitNumber = toNumberAlways(sbSyncExit, 0);
|
||||
|
||||
switch (true) {
|
||||
case mdResyncPos > 0:
|
||||
return mdResyncDtNumber > 0 ? ParityCheckStatus.RUNNING : ParityCheckStatus.PAUSED;
|
||||
case sbSynced === 0:
|
||||
return ParityCheckStatus.NEVER_RUN;
|
||||
case sbSyncExitNumber === -4:
|
||||
return ParityCheckStatus.CANCELLED;
|
||||
case sbSyncExitNumber !== 0:
|
||||
return ParityCheckStatus.FAILED;
|
||||
case sbSynced2 > 0:
|
||||
return ParityCheckStatus.COMPLETED;
|
||||
default:
|
||||
return ParityCheckStatus.NEVER_RUN;
|
||||
}
|
||||
}
|
||||
|
||||
export function getParityCheckStatus(varData: RelevantVarData): ParityCheck {
|
||||
const { sbSynced, sbSynced2, mdResyncDt, mdResyncDb, mdResyncPos, mdResyncSize } = varData;
|
||||
const deltaTime = toNumberAlways(mdResyncDt, 0);
|
||||
const deltaBlocks = toNumberAlways(mdResyncDb, 0);
|
||||
|
||||
// seconds since epoch (unix timestamp)
|
||||
const now = sbSynced2 > 0 ? sbSynced2 : Date.now() / 1000;
|
||||
return {
|
||||
status: getStatusFromVarData(varData),
|
||||
speed: String(calculateParitySpeed(deltaTime, deltaBlocks)),
|
||||
date: sbSynced > 0 ? new Date(sbSynced * 1000) : undefined,
|
||||
duration: sbSynced > 0 ? Math.round(now - sbSynced) : undefined,
|
||||
// percentage as integer, clamped to [0, 100]
|
||||
progress:
|
||||
mdResyncSize <= 0
|
||||
? 0
|
||||
: Math.round(Math.min(100, Math.max(0, (mdResyncPos / mdResyncSize) * 100))),
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export const pubsub = new PubSub({ eventEmitter });
|
||||
* Create a pubsub subscription.
|
||||
* @param channel The pubsub channel to subscribe to.
|
||||
*/
|
||||
export const createSubscription = (channel: GRAPHQL_PUBSUB_CHANNEL) => {
|
||||
return pubsub.asyncIterableIterator(channel);
|
||||
export const createSubscription = <T = any>(
|
||||
channel: GRAPHQL_PUBSUB_CHANNEL
|
||||
): AsyncIterableIterator<T> => {
|
||||
return pubsub.asyncIterableIterator<T>(channel);
|
||||
};
|
||||
|
||||
@@ -68,11 +68,24 @@ export type Var = {
|
||||
mdNumStripes: number;
|
||||
mdNumStripesDefault: number;
|
||||
mdNumStripesStatus: string;
|
||||
/**
|
||||
* Serves a dual purpose depending on context:
|
||||
* - Total size of the operation (in sectors/blocks)
|
||||
* - Running state indicator (0 = paused, >0 = running)
|
||||
*/
|
||||
mdResync: number;
|
||||
mdResyncAction: string;
|
||||
mdResyncCorr: string;
|
||||
mdResyncDb: string;
|
||||
/** Average time interval (delta time) in seconds of current parity operations */
|
||||
mdResyncDt: string;
|
||||
/**
|
||||
* Current position in the parity operation (in sectors/blocks).
|
||||
* When mdResyncPos > 0, a parity operation is active.
|
||||
* When mdResyncPos = 0, no parity operation is running.
|
||||
*
|
||||
* Used to calculate progress percentage.
|
||||
*/
|
||||
mdResyncPos: number;
|
||||
mdResyncSize: number;
|
||||
mdState: ArrayState;
|
||||
@@ -136,9 +149,36 @@ export type Var = {
|
||||
sbName: string;
|
||||
sbNumDisks: number;
|
||||
sbState: string;
|
||||
/**
|
||||
* Unix timestamp when parity operation started.
|
||||
* When sbSynced = 0, indicates no parity check has ever been run.
|
||||
*
|
||||
* Used to calculate elapsed time during active operations.
|
||||
*/
|
||||
sbSynced: number;
|
||||
sbSynced2: number;
|
||||
/**
|
||||
* Unix timestamp when parity operation completed (successfully or with errors).
|
||||
* Used to display completion time in status messages.
|
||||
*
|
||||
* When sbSynced2 = 0, indicates operation started but not yet finished
|
||||
*/
|
||||
sbSyncErrs: number;
|
||||
/**
|
||||
* Exit status code that indicates how the last parity operation completed, following standard Unix conventions.
|
||||
*
|
||||
* sbSyncExit = 0 - Successful Completion
|
||||
* - Parity operation completed normally without errors
|
||||
* - Used to calculate speed and display success message
|
||||
*
|
||||
* sbSyncExit = -4 - Aborted/Cancelled
|
||||
* - Operation was manually cancelled by user
|
||||
* - Displays as "aborted" in the UI
|
||||
*
|
||||
* sbSyncExit ≠ 0 (other values) - Failed/Incomplete
|
||||
* - Operation failed due to errors or other issues
|
||||
* - Displays the numeric error code
|
||||
*/
|
||||
sbSyncExit: string;
|
||||
sbUpdated: string;
|
||||
sbVersion: string;
|
||||
|
||||
@@ -23,6 +23,54 @@ type OptionsWithLoadedFile = {
|
||||
type: ConfigType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Flattens nested objects that were incorrectly created by periods in INI section names.
|
||||
* For example: { system: { with: { periods: {...} } } } -> { "system.with.periods": {...} }
|
||||
*/
|
||||
const flattenPeriodSections = (obj: Record<string, any>, prefix = ''): Record<string, any> => {
|
||||
const result: Record<string, any> = {};
|
||||
const isNestedObject = (value: unknown) =>
|
||||
Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
||||
// prevent prototype pollution/injection
|
||||
const isUnsafeKey = (k: string) => k === '__proto__' || k === 'prototype' || k === 'constructor';
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (isUnsafeKey(key)) continue;
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (!isNestedObject(value)) {
|
||||
result[fullKey] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const section = {};
|
||||
const nestedObjs = {};
|
||||
let hasSectionProps = false;
|
||||
|
||||
for (const [propKey, propValue] of Object.entries(value)) {
|
||||
if (isUnsafeKey(propKey)) continue;
|
||||
if (isNestedObject(propValue)) {
|
||||
nestedObjs[propKey] = propValue;
|
||||
} else {
|
||||
section[propKey] = propValue;
|
||||
hasSectionProps = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Process direct properties first to maintain order
|
||||
if (hasSectionProps) {
|
||||
result[fullKey] = section;
|
||||
}
|
||||
|
||||
// Then process nested objects
|
||||
if (Object.keys(nestedObjs).length > 0) {
|
||||
Object.assign(result, flattenPeriodSections(nestedObjs, fullKey));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts the following
|
||||
* ```
|
||||
@@ -127,6 +175,8 @@ export const parseConfig = <T extends Record<string, any>>(
|
||||
let data: Record<string, any>;
|
||||
try {
|
||||
data = parseIni(fileContents);
|
||||
// Fix nested objects created by periods in section names
|
||||
data = flattenPeriodSections(data);
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
`Failed to parse config file: ${error instanceof Error ? error.message : String(error)}`
|
||||
|
||||
17
api/src/core/utils/validation/enum-validator.ts
Normal file
17
api/src/core/utils/validation/enum-validator.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function isValidEnumValue<T extends Record<string, string | number>>(
|
||||
value: unknown,
|
||||
enumObject: T
|
||||
): value is T[keyof T] {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.values(enumObject).includes(value as T[keyof T]);
|
||||
}
|
||||
|
||||
export function validateEnumValue<T extends Record<string, string | number>>(
|
||||
value: unknown,
|
||||
enumObject: T
|
||||
): T[keyof T] | undefined {
|
||||
return isValidEnumValue(value, enumObject) ? (value as T[keyof T]) : undefined;
|
||||
}
|
||||
@@ -108,3 +108,6 @@ export const PATHS_LOGS_FILE = process.env.PATHS_LOGS_FILE ?? '/var/log/graphql-
|
||||
|
||||
export const PATHS_CONFIG_MODULES =
|
||||
process.env.PATHS_CONFIG_MODULES ?? '/boot/config/plugins/dynamix.my.servers/configs';
|
||||
|
||||
export const PATHS_LOCAL_SESSION_FILE =
|
||||
process.env.PATHS_LOCAL_SESSION_FILE ?? '/var/run/unraid-api/local-session';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
|
||||
import { AuthZGuard } from 'nest-authz';
|
||||
@@ -23,6 +24,7 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u
|
||||
GlobalDepsModule,
|
||||
LegacyConfigModule,
|
||||
PubSubModule,
|
||||
ScheduleModule.forRoot(),
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
logger: apiLogger,
|
||||
|
||||
@@ -2,15 +2,14 @@ import { Logger } from '@nestjs/common';
|
||||
import { readdir, readFile, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { ensureDir, ensureDirSync } from 'fs-extra';
|
||||
import { AuthActionVerb } from 'nest-authz';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { environment } from '@app/environment.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { ApiKey, ApiKeyWithSecret } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { ApiKey } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
|
||||
// Mock the store and its modules
|
||||
vi.mock('@app/store/index.js', () => ({
|
||||
@@ -48,28 +47,14 @@ describe('ApiKeyService', () => {
|
||||
|
||||
const mockApiKey: ApiKey = {
|
||||
id: 'test-api-id',
|
||||
key: 'test-secret-key',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ],
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockApiKeyWithSecret: ApiKeyWithSecret = {
|
||||
id: 'test-api-id',
|
||||
key: 'test-api-key',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -130,21 +115,23 @@ describe('ApiKeyService', () => {
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create ApiKeyWithSecret with generated key', async () => {
|
||||
it('should create ApiKey with generated key', async () => {
|
||||
const saveSpy = vi.spyOn(apiKeyService, 'saveApiKey').mockResolvedValue();
|
||||
const { key, id, description, roles } = mockApiKeyWithSecret;
|
||||
const { id, description, roles } = mockApiKey;
|
||||
const name = 'Test API Key';
|
||||
|
||||
const result = await apiKeyService.create({ name, description: description ?? '', roles });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id,
|
||||
key,
|
||||
name: name,
|
||||
description,
|
||||
roles,
|
||||
createdAt: expect.any(String),
|
||||
});
|
||||
expect(result.key).toBeDefined();
|
||||
expect(typeof result.key).toBe('string');
|
||||
expect(result.key.length).toBeGreaterThan(0);
|
||||
|
||||
expect(saveSpy).toHaveBeenCalledWith(result);
|
||||
});
|
||||
@@ -177,8 +164,8 @@ describe('ApiKeyService', () => {
|
||||
describe('findAll', () => {
|
||||
it('should return all API keys', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([
|
||||
mockApiKeyWithSecret,
|
||||
{ ...mockApiKeyWithSecret, id: 'second-id' },
|
||||
mockApiKey,
|
||||
{ ...mockApiKey, id: 'second-id' },
|
||||
]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
@@ -191,7 +178,7 @@ describe('ApiKeyService', () => {
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -202,7 +189,7 @@ describe('ApiKeyService', () => {
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -219,17 +206,17 @@ describe('ApiKeyService', () => {
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return API key by id when found', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKeyWithSecret]);
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKey]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
const result = await apiKeyService.findById(mockApiKeyWithSecret.id);
|
||||
const result = await apiKeyService.findById(mockApiKey.id);
|
||||
|
||||
expect(result).toMatchObject({ ...mockApiKey, createdAt: expect.any(String) });
|
||||
});
|
||||
|
||||
it('should return null if API key not found', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([
|
||||
{ ...mockApiKeyWithSecret, id: 'different-id' },
|
||||
{ ...mockApiKey, id: 'different-id' },
|
||||
]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
@@ -239,21 +226,21 @@ describe('ApiKeyService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByIdWithSecret', () => {
|
||||
it('should return API key with secret when found', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKeyWithSecret]);
|
||||
describe('findById', () => {
|
||||
it('should return API key when found', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKey]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
const result = await apiKeyService.findByIdWithSecret(mockApiKeyWithSecret.id);
|
||||
const result = await apiKeyService.findById(mockApiKey.id);
|
||||
|
||||
expect(result).toEqual(mockApiKeyWithSecret);
|
||||
expect(result).toEqual(mockApiKey);
|
||||
});
|
||||
|
||||
it('should return null when API key not found', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
const result = await apiKeyService.findByIdWithSecret('non-existent-id');
|
||||
const result = await apiKeyService.findById('non-existent-id');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@@ -274,23 +261,20 @@ describe('ApiKeyService', () => {
|
||||
|
||||
describe('findByKey', () => {
|
||||
it('should return API key by key value when multiple keys exist', async () => {
|
||||
const differentKey = { ...mockApiKeyWithSecret, key: 'different-key' };
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([
|
||||
differentKey,
|
||||
mockApiKeyWithSecret,
|
||||
]);
|
||||
const differentKey = { ...mockApiKey, key: 'different-key' };
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([differentKey, mockApiKey]);
|
||||
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
const result = await apiKeyService.findByKey(mockApiKeyWithSecret.key);
|
||||
const result = await apiKeyService.findByKey(mockApiKey.key);
|
||||
|
||||
expect(result).toEqual(mockApiKeyWithSecret);
|
||||
expect(result).toEqual(mockApiKey);
|
||||
});
|
||||
|
||||
it('should return null if key not found in any file', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([
|
||||
{ ...mockApiKeyWithSecret, key: 'different-key-1' },
|
||||
{ ...mockApiKeyWithSecret, key: 'different-key-2' },
|
||||
{ ...mockApiKey, key: 'different-key-1' },
|
||||
{ ...mockApiKey, key: 'different-key-2' },
|
||||
]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
@@ -314,21 +298,21 @@ describe('ApiKeyService', () => {
|
||||
it('should save API key to file', async () => {
|
||||
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||
|
||||
await apiKeyService.saveApiKey(mockApiKeyWithSecret);
|
||||
await apiKeyService.saveApiKey(mockApiKey);
|
||||
|
||||
const writeFileCalls = vi.mocked(writeFile).mock.calls;
|
||||
|
||||
expect(writeFileCalls.length).toBe(1);
|
||||
|
||||
const [filePath, fileContent] = writeFileCalls[0] ?? [];
|
||||
const expectedPath = join(mockBasePath, `${mockApiKeyWithSecret.id}.json`);
|
||||
const expectedPath = join(mockBasePath, `${mockApiKey.id}.json`);
|
||||
|
||||
expect(filePath).toBe(expectedPath);
|
||||
|
||||
if (typeof fileContent === 'string') {
|
||||
const savedApiKey = JSON.parse(fileContent);
|
||||
|
||||
expect(savedApiKey).toEqual(mockApiKeyWithSecret);
|
||||
expect(savedApiKey).toEqual(mockApiKey);
|
||||
} else {
|
||||
throw new Error('File content should be a string');
|
||||
}
|
||||
@@ -337,16 +321,16 @@ describe('ApiKeyService', () => {
|
||||
it('should throw GraphQLError on write error', async () => {
|
||||
vi.mocked(writeFile).mockRejectedValue(new Error('Write failed'));
|
||||
|
||||
await expect(apiKeyService.saveApiKey(mockApiKeyWithSecret)).rejects.toThrow(
|
||||
await expect(apiKeyService.saveApiKey(mockApiKey)).rejects.toThrow(
|
||||
'Failed to save API key: Write failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw GraphQLError on invalid API key structure', async () => {
|
||||
const invalidApiKey = {
|
||||
...mockApiKeyWithSecret,
|
||||
...mockApiKey,
|
||||
name: '', // Invalid: name cannot be empty
|
||||
} as ApiKeyWithSecret;
|
||||
} as ApiKey;
|
||||
|
||||
await expect(apiKeyService.saveApiKey(invalidApiKey)).rejects.toThrow(
|
||||
'Failed to save API key: Invalid data structure'
|
||||
@@ -355,10 +339,10 @@ describe('ApiKeyService', () => {
|
||||
|
||||
it('should throw GraphQLError when roles and permissions array is empty', async () => {
|
||||
const invalidApiKey = {
|
||||
...mockApiKeyWithSecret,
|
||||
...mockApiKey,
|
||||
permissions: [],
|
||||
roles: [],
|
||||
} as ApiKeyWithSecret;
|
||||
} as ApiKey;
|
||||
|
||||
await expect(apiKeyService.saveApiKey(invalidApiKey)).rejects.toThrow(
|
||||
'At least one of permissions or roles must be specified'
|
||||
@@ -367,9 +351,9 @@ describe('ApiKeyService', () => {
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
let updateMockApiKey: ApiKeyWithSecret;
|
||||
let updateMockApiKey: ApiKey;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
// Create a fresh copy of the mock data for update tests
|
||||
updateMockApiKey = {
|
||||
id: 'test-api-id',
|
||||
@@ -380,15 +364,17 @@ describe('ApiKeyService', () => {
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([updateMockApiKey]);
|
||||
// Initialize the memoryApiKeys with the test data
|
||||
// The loadAllFromDisk mock will be called by onModuleInit
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([{ ...updateMockApiKey }]);
|
||||
vi.spyOn(apiKeyService, 'saveApiKey').mockResolvedValue();
|
||||
apiKeyService.onModuleInit();
|
||||
await apiKeyService.onModuleInit();
|
||||
});
|
||||
|
||||
it('should update name and description', async () => {
|
||||
@@ -400,7 +386,6 @@ describe('ApiKeyService', () => {
|
||||
name: updatedName,
|
||||
description: updatedDescription,
|
||||
});
|
||||
|
||||
expect(result.name).toBe(updatedName);
|
||||
expect(result.description).toBe(updatedDescription);
|
||||
expect(result.roles).toEqual(updateMockApiKey.roles);
|
||||
@@ -427,7 +412,7 @@ describe('ApiKeyService', () => {
|
||||
const updatedPermissions = [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ, AuthActionVerb.UPDATE],
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -474,7 +459,7 @@ describe('ApiKeyService', () => {
|
||||
});
|
||||
|
||||
describe('loadAllFromDisk', () => {
|
||||
let loadMockApiKey: ApiKeyWithSecret;
|
||||
let loadMockApiKey: ApiKey;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh copy of the mock data for loadAllFromDisk tests
|
||||
@@ -487,7 +472,7 @@ describe('ApiKeyService', () => {
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -550,15 +535,62 @@ describe('ApiKeyService', () => {
|
||||
key: 'unique-key',
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize permission actions to lowercase when loading from disk', async () => {
|
||||
const apiKeyWithMixedCaseActions = {
|
||||
...loadMockApiKey,
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: ['READ:ANY', 'Update:Any', 'create:any', 'DELETE:ANY'], // Mixed case actions
|
||||
},
|
||||
{
|
||||
resource: Resource.ARRAY,
|
||||
actions: ['Read:Any'], // Mixed case
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(readdir).mockResolvedValue(['key1.json'] as any);
|
||||
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify(apiKeyWithMixedCaseActions));
|
||||
|
||||
const result = await apiKeyService.loadAllFromDisk();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
// All actions should be normalized to lowercase
|
||||
expect(result[0].permissions[0].actions).toEqual([
|
||||
AuthAction.READ_ANY,
|
||||
AuthAction.UPDATE_ANY,
|
||||
AuthAction.CREATE_ANY,
|
||||
AuthAction.DELETE_ANY,
|
||||
]);
|
||||
expect(result[0].permissions[1].actions).toEqual([AuthAction.READ_ANY]);
|
||||
});
|
||||
|
||||
it('should normalize roles to uppercase when loading from disk', async () => {
|
||||
const apiKeyWithMixedCaseRoles = {
|
||||
...loadMockApiKey,
|
||||
roles: ['admin', 'Viewer', 'CONNECT'], // Mixed case roles
|
||||
};
|
||||
|
||||
vi.mocked(readdir).mockResolvedValue(['key1.json'] as any);
|
||||
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify(apiKeyWithMixedCaseRoles));
|
||||
|
||||
const result = await apiKeyService.loadAllFromDisk();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
// All roles should be normalized to uppercase
|
||||
expect(result[0].roles).toEqual(['ADMIN', 'VIEWER', 'CONNECT']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadApiKeyFile', () => {
|
||||
it('should load and parse a valid API key file', async () => {
|
||||
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockApiKeyWithSecret));
|
||||
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockApiKey));
|
||||
|
||||
const result = await apiKeyService['loadApiKeyFile']('test.json');
|
||||
|
||||
expect(result).toEqual(mockApiKeyWithSecret);
|
||||
expect(result).toEqual(mockApiKey);
|
||||
expect(readFile).toHaveBeenCalledWith(join(mockBasePath, 'test.json'), 'utf8');
|
||||
});
|
||||
|
||||
@@ -592,7 +624,7 @@ describe('ApiKeyService', () => {
|
||||
expect.stringContaining('Error validating API key file test.json')
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('An instance of ApiKeyWithSecret has failed the validation')
|
||||
expect.stringContaining('An instance of ApiKey has failed the validation')
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('property key'));
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('property id'));
|
||||
@@ -603,5 +635,50 @@ describe('ApiKeyService', () => {
|
||||
expect.stringContaining('property permissions')
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize legacy action formats when loading API keys', async () => {
|
||||
const legacyApiKey = {
|
||||
...mockApiKey,
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: ['create', 'READ', 'Update', 'DELETE'], // Mixed case legacy verbs
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: ['READ_ANY', 'UPDATE_OWN'], // GraphQL enum style
|
||||
},
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: ['read:own', 'update:any'], // Casbin colon format
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(readFile).mockResolvedValue(JSON.stringify(legacyApiKey));
|
||||
|
||||
const result = await apiKeyService['loadApiKeyFile']('legacy.json');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.permissions).toEqual([
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [
|
||||
AuthAction.CREATE_ANY,
|
||||
AuthAction.READ_ANY,
|
||||
AuthAction.UPDATE_ANY,
|
||||
AuthAction.DELETE_ANY,
|
||||
],
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_OWN],
|
||||
},
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthAction.READ_OWN, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,12 @@ import crypto from 'crypto';
|
||||
import { readdir, readFile, unlink, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { normalizeLegacyActions } from '@unraid/shared/util/permissions.js';
|
||||
import { watch } from 'chokidar';
|
||||
import { ValidationError } from 'class-validator';
|
||||
import { ensureDirSync } from 'fs-extra';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { AuthActionVerb } from 'nest-authz';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { environment } from '@app/environment.js';
|
||||
@@ -16,7 +16,6 @@ import { getters } from '@app/store/index.js';
|
||||
import {
|
||||
AddPermissionInput,
|
||||
ApiKey,
|
||||
ApiKeyWithSecret,
|
||||
Permission,
|
||||
} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js';
|
||||
@@ -26,7 +25,7 @@ import { batchProcess } from '@app/utils.js';
|
||||
export class ApiKeyService implements OnModuleInit {
|
||||
private readonly logger = new Logger(ApiKeyService.name);
|
||||
protected readonly basePath: string;
|
||||
protected memoryApiKeys: Array<ApiKeyWithSecret> = [];
|
||||
protected memoryApiKeys: Array<ApiKey> = [];
|
||||
private static readonly validRoles: Set<Role> = new Set(Object.values(Role));
|
||||
|
||||
constructor() {
|
||||
@@ -41,18 +40,8 @@ export class ApiKeyService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
public convertApiKeyWithSecretToApiKey(key: ApiKeyWithSecret): ApiKey {
|
||||
const { key: _, ...rest } = key;
|
||||
return rest;
|
||||
}
|
||||
|
||||
public async findAll(): Promise<ApiKey[]> {
|
||||
return Promise.all(
|
||||
this.memoryApiKeys.map(async (key) => {
|
||||
const keyWithoutSecret = this.convertApiKeyWithSecretToApiKey(key);
|
||||
return keyWithoutSecret;
|
||||
})
|
||||
);
|
||||
return this.memoryApiKeys;
|
||||
}
|
||||
|
||||
private setupWatch() {
|
||||
@@ -76,17 +65,18 @@ export class ApiKeyService implements OnModuleInit {
|
||||
public getAllValidPermissions(): Permission[] {
|
||||
return Object.values(Resource).map((res) => ({
|
||||
resource: res,
|
||||
actions: Object.values(AuthActionVerb),
|
||||
actions: Object.values(AuthAction),
|
||||
}));
|
||||
}
|
||||
|
||||
public convertPermissionsStringArrayToPermissions(permissions: string[]): Permission[] {
|
||||
return permissions.reduce<Array<Permission>>((acc, permission) => {
|
||||
const [resource, action] = permission.split(':');
|
||||
const [resource, ...actionParts] = permission.split(':');
|
||||
const action = actionParts.join(':'); // Handle actions like "read:any"
|
||||
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;
|
||||
AuthAction[action.toUpperCase().replace(':', '_') as keyof typeof AuthAction] ?? null;
|
||||
if (validatedAction && validatedResource) {
|
||||
const existingEntry = acc.find((p) => p.resource === validatedResource);
|
||||
if (existingEntry) {
|
||||
@@ -119,7 +109,7 @@ export class ApiKeyService implements OnModuleInit {
|
||||
roles?: Role[];
|
||||
permissions?: Permission[] | AddPermissionInput[];
|
||||
overwrite?: boolean;
|
||||
}): Promise<ApiKeyWithSecret> {
|
||||
}): Promise<ApiKey> {
|
||||
const trimmedName = name?.trim();
|
||||
const sanitizedName = this.sanitizeName(trimmedName);
|
||||
|
||||
@@ -139,7 +129,7 @@ export class ApiKeyService implements OnModuleInit {
|
||||
if (!overwrite && existingKey) {
|
||||
return existingKey;
|
||||
}
|
||||
const apiKey: Partial<ApiKeyWithSecret> = {
|
||||
const apiKey: Partial<ApiKey> = {
|
||||
id: uuidv4(),
|
||||
key: this.generateApiKey(),
|
||||
name: sanitizedName,
|
||||
@@ -152,18 +142,18 @@ export class ApiKeyService implements OnModuleInit {
|
||||
// Update createdAt date
|
||||
apiKey.createdAt = new Date().toISOString();
|
||||
|
||||
await this.saveApiKey(apiKey as ApiKeyWithSecret);
|
||||
await this.saveApiKey(apiKey as ApiKey);
|
||||
|
||||
return apiKey as ApiKeyWithSecret;
|
||||
return apiKey as ApiKey;
|
||||
}
|
||||
|
||||
async loadAllFromDisk(): Promise<ApiKeyWithSecret[]> {
|
||||
async loadAllFromDisk(): Promise<ApiKey[]> {
|
||||
const files = await readdir(this.basePath).catch((error) => {
|
||||
this.logger.error(`Failed to read API key directory: ${error}`);
|
||||
throw new Error('Failed to list API keys');
|
||||
});
|
||||
|
||||
const apiKeys: ApiKeyWithSecret[] = [];
|
||||
const apiKeys: ApiKey[] = [];
|
||||
const jsonFiles = files.filter((file) => file.includes('.json'));
|
||||
|
||||
for (const file of jsonFiles) {
|
||||
@@ -186,7 +176,7 @@ export class ApiKeyService implements OnModuleInit {
|
||||
* @param file The file to load
|
||||
* @returns The API key with secret
|
||||
*/
|
||||
private async loadApiKeyFile(file: string): Promise<ApiKeyWithSecret | null> {
|
||||
private async loadApiKeyFile(file: string): Promise<ApiKey | null> {
|
||||
try {
|
||||
const content = await readFile(join(this.basePath, file), 'utf8');
|
||||
|
||||
@@ -196,7 +186,17 @@ export class ApiKeyService implements OnModuleInit {
|
||||
if (parsedContent.roles) {
|
||||
parsedContent.roles = parsedContent.roles.map((role: string) => role.toUpperCase());
|
||||
}
|
||||
return await validateObject(ApiKeyWithSecret, parsedContent);
|
||||
|
||||
// Normalize permission actions to AuthAction enum values
|
||||
// Uses shared helper to handle all legacy formats
|
||||
if (parsedContent.permissions) {
|
||||
parsedContent.permissions = parsedContent.permissions.map((permission: any) => ({
|
||||
...permission,
|
||||
actions: normalizeLegacyActions(permission.actions || []),
|
||||
}));
|
||||
}
|
||||
|
||||
return await validateObject(ApiKey, parsedContent);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
this.logger.error(`Corrupted key file: ${file}`);
|
||||
@@ -216,12 +216,7 @@ export class ApiKeyService implements OnModuleInit {
|
||||
|
||||
async findById(id: string): Promise<ApiKey | null> {
|
||||
try {
|
||||
const key = this.findByField('id', id);
|
||||
|
||||
if (key) {
|
||||
return this.convertApiKeyWithSecretToApiKey(key);
|
||||
}
|
||||
return null;
|
||||
return this.findByField('id', id);
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
this.logApiKeyValidationError(id, error);
|
||||
@@ -231,17 +226,13 @@ export class ApiKeyService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
public findByIdWithSecret(id: string): ApiKeyWithSecret | null {
|
||||
return this.findByField('id', id);
|
||||
}
|
||||
|
||||
public findByField(field: keyof ApiKeyWithSecret, value: string): ApiKeyWithSecret | null {
|
||||
public findByField(field: keyof ApiKey, value: string): ApiKey | null {
|
||||
if (!value) return null;
|
||||
|
||||
return this.memoryApiKeys.find((k) => k[field] === value) ?? null;
|
||||
}
|
||||
|
||||
findByKey(key: string): ApiKeyWithSecret | null {
|
||||
findByKey(key: string): ApiKey | null {
|
||||
return this.findByField('key', key);
|
||||
}
|
||||
|
||||
@@ -254,9 +245,9 @@ export class ApiKeyService implements OnModuleInit {
|
||||
Errors: ${JSON.stringify(error.constraints, null, 2)}`);
|
||||
}
|
||||
|
||||
public async saveApiKey(apiKey: ApiKeyWithSecret): Promise<void> {
|
||||
public async saveApiKey(apiKey: ApiKey): Promise<void> {
|
||||
try {
|
||||
const validatedApiKey = await validateObject(ApiKeyWithSecret, apiKey);
|
||||
const validatedApiKey = await validateObject(ApiKey, apiKey);
|
||||
if (!validatedApiKey.permissions?.length && !validatedApiKey.roles?.length) {
|
||||
throw new GraphQLError('At least one of permissions or roles must be specified');
|
||||
}
|
||||
@@ -266,7 +257,7 @@ export class ApiKeyService implements OnModuleInit {
|
||||
.reduce((acc, key) => {
|
||||
acc[key] = validatedApiKey[key];
|
||||
return acc;
|
||||
}, {} as ApiKeyWithSecret);
|
||||
}, {} as ApiKey);
|
||||
|
||||
await writeFile(
|
||||
join(this.basePath, `${validatedApiKey.id}.json`),
|
||||
@@ -334,8 +325,8 @@ export class ApiKeyService implements OnModuleInit {
|
||||
description?: string;
|
||||
roles?: Role[];
|
||||
permissions?: Permission[] | AddPermissionInput[];
|
||||
}): Promise<ApiKeyWithSecret> {
|
||||
const apiKey = this.findByIdWithSecret(id);
|
||||
}): Promise<ApiKey> {
|
||||
const apiKey = await this.findById(id);
|
||||
if (!apiKey) {
|
||||
throw new GraphQLError('API key not found');
|
||||
}
|
||||
@@ -345,13 +336,15 @@ export class ApiKeyService implements OnModuleInit {
|
||||
if (description !== undefined) {
|
||||
apiKey.description = description;
|
||||
}
|
||||
if (roles) {
|
||||
if (roles !== undefined) {
|
||||
// Handle both empty array (to clear roles) and populated array
|
||||
if (roles.some((role) => !ApiKeyService.validRoles.has(role))) {
|
||||
throw new GraphQLError('Invalid role specified');
|
||||
}
|
||||
apiKey.roles = roles;
|
||||
}
|
||||
if (permissions) {
|
||||
if (permissions !== undefined) {
|
||||
// Handle both empty array (to clear permissions) and populated array
|
||||
apiKey.permissions = permissions;
|
||||
}
|
||||
await this.saveApiKey(apiKey);
|
||||
|
||||
@@ -11,13 +11,19 @@ import { BASE_POLICY, CASBIN_MODEL } from '@app/unraid-api/auth/casbin/index.js'
|
||||
import { CookieService, SESSION_COOKIE_CONFIG } from '@app/unraid-api/auth/cookie.service.js';
|
||||
import { UserCookieStrategy } from '@app/unraid-api/auth/cookie.strategy.js';
|
||||
import { ServerHeaderStrategy } from '@app/unraid-api/auth/header.strategy.js';
|
||||
import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
|
||||
import { LocalSessionLifecycleService } from '@app/unraid-api/auth/local-session-lifecycle.service.js';
|
||||
import { LocalSessionService } from '@app/unraid-api/auth/local-session.service.js';
|
||||
import { LocalSessionStrategy } from '@app/unraid-api/auth/local-session.strategy.js';
|
||||
import { getRequest } from '@app/utils.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({
|
||||
defaultStrategy: [ServerHeaderStrategy.key, UserCookieStrategy.key],
|
||||
defaultStrategy: [
|
||||
ServerHeaderStrategy.key,
|
||||
LocalSessionStrategy.key,
|
||||
UserCookieStrategy.key,
|
||||
],
|
||||
}),
|
||||
CasbinModule,
|
||||
AuthZModule.register({
|
||||
@@ -51,10 +57,12 @@ import { getRequest } from '@app/utils.js';
|
||||
providers: [
|
||||
AuthService,
|
||||
ApiKeyService,
|
||||
AdminKeyService,
|
||||
ServerHeaderStrategy,
|
||||
LocalSessionStrategy,
|
||||
UserCookieStrategy,
|
||||
CookieService,
|
||||
LocalSessionService,
|
||||
LocalSessionLifecycleService,
|
||||
{
|
||||
provide: SESSION_COOKIE_CONFIG,
|
||||
useValue: CookieService.defaultOpts(),
|
||||
@@ -65,8 +73,11 @@ import { getRequest } from '@app/utils.js';
|
||||
ApiKeyService,
|
||||
PassportModule,
|
||||
ServerHeaderStrategy,
|
||||
LocalSessionStrategy,
|
||||
UserCookieStrategy,
|
||||
CookieService,
|
||||
LocalSessionService,
|
||||
LocalSessionLifecycleService,
|
||||
AuthZModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { newEnforcer } from 'casbin';
|
||||
import { AuthActionVerb, AuthZService } from 'nest-authz';
|
||||
import { AuthZService } from 'nest-authz';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
|
||||
import { ApiKey, ApiKeyWithSecret } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { LocalSessionService } from '@app/unraid-api/auth/local-session.service.js';
|
||||
import { ApiKey } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { UserAccount } from '@app/unraid-api/graph/user/user.model.js';
|
||||
import { FastifyRequest } from '@app/unraid-api/types/fastify.js';
|
||||
|
||||
@@ -17,17 +18,9 @@ describe('AuthService', () => {
|
||||
let apiKeyService: ApiKeyService;
|
||||
let authzService: AuthZService;
|
||||
let cookieService: CookieService;
|
||||
let localSessionService: LocalSessionService;
|
||||
|
||||
const mockApiKey: ApiKey = {
|
||||
id: '10f356da-1e9e-43b8-9028-a26a645539a6',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST, Role.CONNECT],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
const mockApiKeyWithSecret: ApiKeyWithSecret = {
|
||||
id: 'test-api-id',
|
||||
key: 'test-api-key',
|
||||
name: 'Test API Key',
|
||||
@@ -36,7 +29,7 @@ describe('AuthService', () => {
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ.toUpperCase()],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -64,7 +57,10 @@ describe('AuthService', () => {
|
||||
apiKeyService = new ApiKeyService();
|
||||
authzService = new AuthZService(enforcer);
|
||||
cookieService = new CookieService();
|
||||
authService = new AuthService(cookieService, apiKeyService, authzService);
|
||||
localSessionService = {
|
||||
validateLocalSession: vi.fn(),
|
||||
} as any;
|
||||
authService = new AuthService(cookieService, apiKeyService, localSessionService, authzService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -98,6 +94,43 @@ describe('AuthService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate API key with only permissions (no roles)', async () => {
|
||||
const apiKeyWithOnlyPermissions: ApiKey = {
|
||||
...mockApiKey,
|
||||
roles: [], // No roles, only permissions
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.spyOn(apiKeyService, 'findByKey').mockResolvedValue(apiKeyWithOnlyPermissions);
|
||||
vi.spyOn(authService, 'syncApiKeyRoles').mockResolvedValue(undefined);
|
||||
vi.spyOn(authService, 'syncApiKeyPermissions').mockResolvedValue(undefined);
|
||||
vi.spyOn(authzService, 'getRolesForUser').mockResolvedValue([]);
|
||||
|
||||
const result = await authService.validateApiKeyCasbin('test-api-key');
|
||||
|
||||
expect(result).toEqual({
|
||||
id: apiKeyWithOnlyPermissions.id,
|
||||
name: apiKeyWithOnlyPermissions.name,
|
||||
description: apiKeyWithOnlyPermissions.description,
|
||||
roles: [],
|
||||
permissions: apiKeyWithOnlyPermissions.permissions,
|
||||
});
|
||||
expect(authService.syncApiKeyRoles).toHaveBeenCalledWith(apiKeyWithOnlyPermissions.id, []);
|
||||
expect(authService.syncApiKeyPermissions).toHaveBeenCalledWith(
|
||||
apiKeyWithOnlyPermissions.id,
|
||||
apiKeyWithOnlyPermissions.permissions
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when session user is missing', async () => {
|
||||
vi.spyOn(cookieService, 'hasValidAuthCookie').mockResolvedValue(true);
|
||||
vi.spyOn(authService, 'getSessionUser').mockResolvedValue(null as unknown as UserAccount);
|
||||
@@ -195,10 +228,6 @@ describe('AuthService', () => {
|
||||
};
|
||||
|
||||
vi.spyOn(apiKeyService, 'findById').mockResolvedValue(mockApiKeyWithoutRole);
|
||||
vi.spyOn(apiKeyService, 'findByIdWithSecret').mockResolvedValue({
|
||||
...mockApiKeyWithSecret,
|
||||
roles: [Role.ADMIN],
|
||||
});
|
||||
vi.spyOn(apiKeyService, 'saveApiKey').mockResolvedValue();
|
||||
vi.spyOn(authzService, 'addRoleForUser').mockResolvedValue(true);
|
||||
|
||||
@@ -206,9 +235,8 @@ describe('AuthService', () => {
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(apiKeyService.findById).toHaveBeenCalledWith(apiKeyId);
|
||||
expect(apiKeyService.findByIdWithSecret).toHaveBeenCalledWith(apiKeyId);
|
||||
expect(apiKeyService.saveApiKey).toHaveBeenCalledWith({
|
||||
...mockApiKeyWithSecret,
|
||||
...mockApiKeyWithoutRole,
|
||||
roles: [Role.ADMIN, role],
|
||||
});
|
||||
expect(authzService.addRoleForUser).toHaveBeenCalledWith(apiKeyId, role);
|
||||
@@ -226,13 +254,8 @@ describe('AuthService', () => {
|
||||
describe('removeRoleFromApiKey', () => {
|
||||
it('should remove role from API key', async () => {
|
||||
const apiKey = { ...mockApiKey, roles: [Role.ADMIN, Role.GUEST] };
|
||||
const apiKeyWithSecret = {
|
||||
...mockApiKeyWithSecret,
|
||||
roles: [Role.ADMIN, Role.GUEST],
|
||||
};
|
||||
|
||||
vi.spyOn(apiKeyService, 'findById').mockResolvedValue(apiKey);
|
||||
vi.spyOn(apiKeyService, 'findByIdWithSecret').mockResolvedValue(apiKeyWithSecret);
|
||||
vi.spyOn(apiKeyService, 'saveApiKey').mockResolvedValue();
|
||||
vi.spyOn(authzService, 'deleteRoleForUser').mockResolvedValue(true);
|
||||
|
||||
@@ -240,9 +263,8 @@ describe('AuthService', () => {
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(apiKeyService.findById).toHaveBeenCalledWith(apiKey.id);
|
||||
expect(apiKeyService.findByIdWithSecret).toHaveBeenCalledWith(apiKey.id);
|
||||
expect(apiKeyService.saveApiKey).toHaveBeenCalledWith({
|
||||
...apiKeyWithSecret,
|
||||
...apiKey,
|
||||
roles: [Role.GUEST],
|
||||
});
|
||||
expect(authzService.deleteRoleForUser).toHaveBeenCalledWith(apiKey.id, Role.ADMIN);
|
||||
@@ -256,4 +278,229 @@ describe('AuthService', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VIEWER role API_KEY access restriction', () => {
|
||||
it('should deny VIEWER role access to API_KEY resource', async () => {
|
||||
// Test that VIEWER role cannot access API_KEY resource
|
||||
const mockCasbinPermissions = Object.values(Resource)
|
||||
.filter((resource) => resource !== Resource.API_KEY)
|
||||
.map((resource) => ['VIEWER', resource, AuthAction.READ_ANY]);
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.VIEWER);
|
||||
|
||||
// VIEWER should have read access to all resources EXCEPT API_KEY
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBeGreaterThan(0);
|
||||
|
||||
// Should NOT have API_KEY in the permissions
|
||||
expect(result.has(Resource.API_KEY)).toBe(false);
|
||||
|
||||
// Should have read access to other resources
|
||||
expect(result.get(Resource.DOCKER)).toEqual([AuthAction.READ_ANY]);
|
||||
expect(result.get(Resource.ARRAY)).toEqual([AuthAction.READ_ANY]);
|
||||
expect(result.get(Resource.CONFIG)).toEqual([AuthAction.READ_ANY]);
|
||||
expect(result.get(Resource.ME)).toEqual([AuthAction.READ_ANY]);
|
||||
});
|
||||
|
||||
it('should allow ADMIN role access to API_KEY resource', async () => {
|
||||
// Test that ADMIN role CAN access API_KEY resource
|
||||
const mockCasbinPermissions = [
|
||||
['ADMIN', '*', '*'], // Admin has wildcard access
|
||||
];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
// ADMIN should have access to API_KEY through wildcard
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.has(Resource.API_KEY)).toBe(true);
|
||||
expect(result.get(Resource.API_KEY)).toContain(AuthAction.CREATE_ANY);
|
||||
expect(result.get(Resource.API_KEY)).toContain(AuthAction.READ_ANY);
|
||||
expect(result.get(Resource.API_KEY)).toContain(AuthAction.UPDATE_ANY);
|
||||
expect(result.get(Resource.API_KEY)).toContain(AuthAction.DELETE_ANY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getImplicitPermissionsForRole', () => {
|
||||
it('should return permissions for a role', async () => {
|
||||
const mockCasbinPermissions = [
|
||||
['ADMIN', 'DOCKER', 'READ'],
|
||||
['ADMIN', 'DOCKER', 'UPDATE'],
|
||||
['ADMIN', 'VMS', 'READ'],
|
||||
];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get(Resource.DOCKER)).toEqual([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]);
|
||||
expect(result.get(Resource.VMS)).toEqual([AuthAction.READ_ANY]);
|
||||
});
|
||||
|
||||
it('should handle wildcard permissions for admin role', async () => {
|
||||
const mockCasbinPermissions = [
|
||||
['ADMIN', '*', '*'],
|
||||
['ADMIN', 'ME', 'READ'], // Inherited from GUEST
|
||||
];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBeGreaterThan(0);
|
||||
// Should have expanded CRUD actions with proper format for all resources
|
||||
expect(result.get(Resource.DOCKER)).toContain(AuthAction.CREATE_ANY);
|
||||
expect(result.get(Resource.DOCKER)).toContain(AuthAction.READ_ANY);
|
||||
expect(result.get(Resource.DOCKER)).toContain(AuthAction.UPDATE_ANY);
|
||||
expect(result.get(Resource.DOCKER)).toContain(AuthAction.DELETE_ANY);
|
||||
expect(result.get(Resource.VMS)).toContain(AuthAction.CREATE_ANY);
|
||||
expect(result.get(Resource.VMS)).toContain(AuthAction.READ_ANY);
|
||||
expect(result.get(Resource.VMS)).toContain(AuthAction.UPDATE_ANY);
|
||||
expect(result.get(Resource.VMS)).toContain(AuthAction.DELETE_ANY);
|
||||
expect(result.get(Resource.ME)).toContain(AuthAction.READ_ANY);
|
||||
expect(result.get(Resource.ME)).toContain(AuthAction.CREATE_ANY); // Also gets CRUD from wildcard
|
||||
expect(result.has('*' as any)).toBe(false); // Still shouldn't have literal wildcard
|
||||
});
|
||||
|
||||
it('should handle connect role with wildcard resource and specific action', async () => {
|
||||
const mockCasbinPermissions = [
|
||||
['CONNECT', '*', 'READ'],
|
||||
['CONNECT', 'CONNECT__REMOTE_ACCESS', 'UPDATE'],
|
||||
['CONNECT', 'ME', 'READ'], // Inherited from GUEST
|
||||
];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.CONNECT);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBeGreaterThan(0);
|
||||
// All resources should have READ
|
||||
expect(result.get(Resource.DOCKER)).toContain(AuthAction.READ_ANY);
|
||||
expect(result.get(Resource.VMS)).toContain(AuthAction.READ_ANY);
|
||||
expect(result.get(Resource.ARRAY)).toContain(AuthAction.READ_ANY);
|
||||
// CONNECT__REMOTE_ACCESS should have both READ and UPDATE
|
||||
expect(result.get(Resource.CONNECT__REMOTE_ACCESS)).toContain(AuthAction.READ_ANY);
|
||||
expect(result.get(Resource.CONNECT__REMOTE_ACCESS)).toContain(AuthAction.UPDATE_ANY);
|
||||
});
|
||||
|
||||
it('should expand resource-specific wildcard actions to CRUD', async () => {
|
||||
const mockCasbinPermissions = [
|
||||
['DOCKER_MANAGER', 'DOCKER', '*'],
|
||||
['DOCKER_MANAGER', 'ARRAY', 'READ'],
|
||||
];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
// Docker should have all CRUD actions with proper format
|
||||
expect(result.get(Resource.DOCKER)).toEqual(
|
||||
expect.arrayContaining([
|
||||
AuthAction.CREATE_ANY,
|
||||
AuthAction.READ_ANY,
|
||||
AuthAction.UPDATE_ANY,
|
||||
AuthAction.DELETE_ANY,
|
||||
])
|
||||
);
|
||||
// Array should only have READ
|
||||
expect(result.get(Resource.ARRAY)).toEqual([AuthAction.READ_ANY]);
|
||||
});
|
||||
|
||||
it('should skip invalid resources', async () => {
|
||||
const mockCasbinPermissions = [
|
||||
['ADMIN', 'INVALID_RESOURCE', 'READ'],
|
||||
['ADMIN', 'DOCKER', 'UPDATE'],
|
||||
['ADMIN', '', 'READ'],
|
||||
] as string[][];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(Resource.DOCKER)).toEqual([AuthAction.UPDATE_ANY]);
|
||||
});
|
||||
|
||||
it('should handle empty permissions', async () => {
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue([]);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle malformed permission entries', async () => {
|
||||
const mockCasbinPermissions = [
|
||||
['ADMIN'], // Too short
|
||||
['ADMIN', 'DOCKER'], // Missing action
|
||||
['ADMIN', 'DOCKER', 'READ', 'EXTRA'], // Extra fields are ok
|
||||
['ADMIN', 'VMS', 'UPDATE'],
|
||||
];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get(Resource.DOCKER)).toEqual([AuthAction.READ_ANY]);
|
||||
expect(result.get(Resource.VMS)).toEqual([AuthAction.UPDATE_ANY]);
|
||||
});
|
||||
|
||||
it('should not duplicate actions for the same resource', async () => {
|
||||
const mockCasbinPermissions = [
|
||||
['ADMIN', 'DOCKER', 'READ'],
|
||||
['ADMIN', 'DOCKER', 'READ'],
|
||||
['ADMIN', 'DOCKER', 'UPDATE'],
|
||||
['ADMIN', 'DOCKER', 'UPDATE'],
|
||||
];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(Resource.DOCKER)).toEqual([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockRejectedValue(
|
||||
new Error('Casbin error')
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import { Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
convertPermissionSetsToArrays,
|
||||
expandWildcardAction,
|
||||
parseActionToAuthAction,
|
||||
reconcileWildcardPermissions,
|
||||
} from '@unraid/shared/util/permissions.js';
|
||||
import { AuthZService } from 'nest-authz';
|
||||
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
|
||||
import { LocalSessionService } from '@app/unraid-api/auth/local-session.service.js';
|
||||
import { Permission } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { UserAccount } from '@app/unraid-api/graph/user/user.model.js';
|
||||
import { FastifyRequest } from '@app/unraid-api/types/fastify.js';
|
||||
@@ -18,6 +26,7 @@ export class AuthService {
|
||||
constructor(
|
||||
private cookieService: CookieService,
|
||||
private apiKeyService: ApiKeyService,
|
||||
private localSessionService: LocalSessionService,
|
||||
private authzService: AuthZService
|
||||
) {}
|
||||
|
||||
@@ -83,6 +92,30 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async validateLocalSession(localSessionToken: string): Promise<UserAccount> {
|
||||
try {
|
||||
const isValid = await this.localSessionService.validateLocalSession(localSessionToken);
|
||||
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Invalid local session token');
|
||||
}
|
||||
|
||||
// Local session has admin privileges
|
||||
const user = await this.getLocalSessionUser();
|
||||
|
||||
// Sync the user's roles before checking them
|
||||
await this.syncUserRoles(user.id, user.roles);
|
||||
|
||||
// Now get the updated roles
|
||||
const existingRoles = await this.authzService.getRolesForUser(user.id);
|
||||
this.logger.debug(`Local session user ${user.id} has roles: ${existingRoles}`);
|
||||
|
||||
return user;
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(this.logger, 'Failed to validate local session', error);
|
||||
}
|
||||
}
|
||||
|
||||
public async syncApiKeyRoles(apiKeyId: string, roles: string[]): Promise<void> {
|
||||
try {
|
||||
// Get existing roles and convert to Set
|
||||
@@ -111,12 +144,36 @@ export class AuthService {
|
||||
await this.authzService.deletePermissionsForUser(apiKeyId);
|
||||
|
||||
// Create array of permission-action pairs for processing
|
||||
const permissionActions = permissions.flatMap((permission) =>
|
||||
(permission.actions || []).map((action) => ({
|
||||
resource: permission.resource,
|
||||
action,
|
||||
}))
|
||||
);
|
||||
// Filter out any permissions with empty or undefined resources
|
||||
const permissionActions = permissions
|
||||
.filter((permission) => permission.resource && permission.resource.trim() !== '')
|
||||
.flatMap((permission) =>
|
||||
(permission.actions || [])
|
||||
.filter((action) => action && String(action).trim() !== '')
|
||||
.flatMap((action) => {
|
||||
const actionStr = String(action);
|
||||
// Handle wildcard - expand to all CRUD actions
|
||||
if (actionStr === '*' || actionStr.toLowerCase() === '*') {
|
||||
return expandWildcardAction().map((expandedAction) => ({
|
||||
resource: permission.resource,
|
||||
action: expandedAction,
|
||||
}));
|
||||
}
|
||||
|
||||
// Use the shared helper to parse and validate the action
|
||||
const parsedAction = parseActionToAuthAction(actionStr);
|
||||
|
||||
// Only include valid AuthAction values
|
||||
return parsedAction
|
||||
? [
|
||||
{
|
||||
resource: permission.resource,
|
||||
action: parsedAction,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
})
|
||||
);
|
||||
|
||||
const { errors, errorOccurred: errorOccured } = await batchProcess(
|
||||
permissionActions,
|
||||
@@ -144,15 +201,12 @@ export class AuthService {
|
||||
}
|
||||
|
||||
try {
|
||||
if (!apiKey.roles) {
|
||||
apiKey.roles = [];
|
||||
}
|
||||
if (!apiKey.roles.includes(role)) {
|
||||
const apiKeyWithSecret = await this.apiKeyService.findByIdWithSecret(apiKeyId);
|
||||
|
||||
if (!apiKeyWithSecret) {
|
||||
throw new UnauthorizedException('API key not found with secret');
|
||||
}
|
||||
|
||||
apiKeyWithSecret.roles.push(role);
|
||||
await this.apiKeyService.saveApiKey(apiKeyWithSecret);
|
||||
apiKey.roles.push(role);
|
||||
await this.apiKeyService.saveApiKey(apiKey);
|
||||
await this.authzService.addRoleForUser(apiKeyId, role);
|
||||
}
|
||||
|
||||
@@ -174,14 +228,11 @@ export class AuthService {
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKeyWithSecret = await this.apiKeyService.findByIdWithSecret(apiKeyId);
|
||||
|
||||
if (!apiKeyWithSecret) {
|
||||
throw new UnauthorizedException('API key not found with secret');
|
||||
if (!apiKey.roles) {
|
||||
apiKey.roles = [];
|
||||
}
|
||||
|
||||
apiKeyWithSecret.roles = apiKeyWithSecret.roles.filter((r) => r !== role);
|
||||
await this.apiKeyService.saveApiKey(apiKeyWithSecret);
|
||||
apiKey.roles = apiKey.roles.filter((r) => r !== role);
|
||||
await this.apiKeyService.saveApiKey(apiKey);
|
||||
await this.authzService.deleteRoleForUser(apiKeyId, role);
|
||||
|
||||
return true;
|
||||
@@ -224,7 +275,67 @@ export class AuthService {
|
||||
}
|
||||
|
||||
public validateCsrfToken(token?: string): boolean {
|
||||
return Boolean(token) && token === getters.emhttp().var.csrfToken;
|
||||
if (!token) return false;
|
||||
const csrfToken = getters.emhttp().var.csrfToken;
|
||||
if (!csrfToken) return false;
|
||||
return timingSafeEqual(Buffer.from(token, 'utf-8'), Buffer.from(csrfToken, 'utf-8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get implicit permissions for a role (including inherited permissions)
|
||||
*/
|
||||
public async getImplicitPermissionsForRole(role: Role): Promise<Map<Resource, AuthAction[]>> {
|
||||
// Use Set internally for efficient deduplication, with '*' as a special key for wildcards
|
||||
const permissionsWithSets = new Map<Resource | '*', Set<AuthAction>>();
|
||||
|
||||
// Load permissions from Casbin, defaulting to empty array on error
|
||||
let casbinPermissions: string[][] = [];
|
||||
try {
|
||||
casbinPermissions = await this.authzService.getImplicitPermissionsForUser(role);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get permissions for role ${role}:`, error);
|
||||
}
|
||||
|
||||
// Parse the Casbin permissions format: [["role", "resource", "action"], ...]
|
||||
for (const perm of casbinPermissions) {
|
||||
if (perm.length < 3) continue;
|
||||
|
||||
const resourceStr = perm[1];
|
||||
const action = perm[2];
|
||||
|
||||
if (!resourceStr) continue;
|
||||
|
||||
// Skip invalid resources (except wildcard)
|
||||
if (resourceStr !== '*' && !Object.values(Resource).includes(resourceStr as Resource)) {
|
||||
this.logger.debug(`Skipping invalid resource from Casbin: ${resourceStr}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Initialize Set if needed
|
||||
if (!permissionsWithSets.has(resourceStr as Resource | '*')) {
|
||||
permissionsWithSets.set(resourceStr as Resource | '*', new Set());
|
||||
}
|
||||
|
||||
const actionsSet = permissionsWithSets.get(resourceStr as Resource | '*')!;
|
||||
|
||||
// Handle wildcard or parse to valid AuthAction
|
||||
if (action === '*') {
|
||||
// Expand wildcard action to CRUD operations
|
||||
expandWildcardAction().forEach((a) => actionsSet.add(a));
|
||||
} else {
|
||||
// Use shared helper to parse and validate action
|
||||
const parsedAction = parseActionToAuthAction(action);
|
||||
if (parsedAction) {
|
||||
actionsSet.add(parsedAction);
|
||||
} else {
|
||||
this.logger.debug(`Skipping invalid action from Casbin: ${action}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile wildcard permissions and convert to final format
|
||||
reconcileWildcardPermissions(permissionsWithSets);
|
||||
return convertPermissionSetsToArrays(permissionsWithSets);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,7 +345,7 @@ export class AuthService {
|
||||
* @returns a service account that represents the user session (i.e. a webgui user).
|
||||
*/
|
||||
async getSessionUser(): Promise<UserAccount> {
|
||||
this.logger.debug('getSessionUser called!');
|
||||
this.logger.verbose('getSessionUser called!');
|
||||
return {
|
||||
id: '-1',
|
||||
description: 'Session receives administrator permissions',
|
||||
@@ -243,4 +354,21 @@ export class AuthService {
|
||||
permissions: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a user object representing a local session.
|
||||
* Note: Does NOT perform validation.
|
||||
*
|
||||
* @returns a service account that represents the local session user (i.e. CLI/system operations).
|
||||
*/
|
||||
async getLocalSessionUser(): Promise<UserAccount> {
|
||||
this.logger.verbose('getLocalSessionUser called!');
|
||||
return {
|
||||
id: '-2',
|
||||
description: 'Local session receives administrator permissions for CLI/system operations',
|
||||
name: 'local-admin',
|
||||
roles: [Role.ADMIN],
|
||||
permissions: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { FastifyRequest } from '@app/unraid-api/types/fastify.js';
|
||||
import { apiLogger } from '@app/core/log.js';
|
||||
import { UserCookieStrategy } from '@app/unraid-api/auth/cookie.strategy.js';
|
||||
import { ServerHeaderStrategy } from '@app/unraid-api/auth/header.strategy.js';
|
||||
import { LocalSessionStrategy } from '@app/unraid-api/auth/local-session.strategy.js';
|
||||
import { IS_PUBLIC_ENDPOINT_KEY } from '@app/unraid-api/auth/public.decorator.js';
|
||||
|
||||
/**
|
||||
@@ -37,7 +38,7 @@ type GraphQLContext =
|
||||
|
||||
@Injectable()
|
||||
export class AuthenticationGuard
|
||||
extends AuthGuard([ServerHeaderStrategy.key, UserCookieStrategy.key])
|
||||
extends AuthGuard([ServerHeaderStrategy.key, LocalSessionStrategy.key, UserCookieStrategy.key])
|
||||
implements CanActivate
|
||||
{
|
||||
protected logger = new Logger(AuthenticationGuard.name);
|
||||
|
||||
@@ -12,7 +12,7 @@ g = _, _
|
||||
e = some(where (p.eft == allow))
|
||||
|
||||
[matchers]
|
||||
m = (regexMatch(r.sub, p.sub) || g(r.sub, p.sub)) && \
|
||||
regexMatch(lower(r.obj), lower(p.obj)) && \
|
||||
(regexMatch(lower(r.act), lower(p.act)) || p.act == '*' || regexMatch(lower(r.act), lower(concat(p.act, ':.*'))))
|
||||
m = (r.sub == p.sub || g(r.sub, p.sub)) && \
|
||||
(r.obj == p.obj || p.obj == '*') && \
|
||||
(r.act == p.act || p.act == '*')
|
||||
`;
|
||||
|
||||
566
api/src/unraid-api/auth/casbin/permissions-comprehensive.spec.ts
Normal file
566
api/src/unraid-api/auth/casbin/permissions-comprehensive.spec.ts
Normal file
@@ -0,0 +1,566 @@
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { Model as CasbinModel, newEnforcer, StringAdapter } from 'casbin';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { CASBIN_MODEL } from '@app/unraid-api/auth/casbin/model.js';
|
||||
import { BASE_POLICY } from '@app/unraid-api/auth/casbin/policy.js';
|
||||
|
||||
describe('Comprehensive Casbin Permissions Tests', () => {
|
||||
describe('All UsePermissions decorator combinations', () => {
|
||||
// Test all resource/action combinations used in the codebase
|
||||
const testCases = [
|
||||
// API_KEY permissions
|
||||
{
|
||||
resource: Resource.API_KEY,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
|
||||
},
|
||||
{
|
||||
resource: Resource.API_KEY,
|
||||
action: AuthAction.CREATE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
|
||||
},
|
||||
{
|
||||
resource: Resource.API_KEY,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
|
||||
},
|
||||
{
|
||||
resource: Resource.API_KEY,
|
||||
action: AuthAction.DELETE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
|
||||
},
|
||||
|
||||
// PERMISSION resource (for listing possible permissions)
|
||||
{
|
||||
resource: Resource.PERMISSION,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
|
||||
// ARRAY permissions
|
||||
{
|
||||
resource: Resource.ARRAY,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.ARRAY,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST],
|
||||
},
|
||||
|
||||
// CONFIG permissions
|
||||
{
|
||||
resource: Resource.CONFIG,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.CONFIG,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
|
||||
},
|
||||
|
||||
// DOCKER permissions
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST],
|
||||
},
|
||||
|
||||
// VMS permissions
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST],
|
||||
},
|
||||
|
||||
// FLASH permissions (includes rclone operations)
|
||||
{
|
||||
resource: Resource.FLASH,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.FLASH,
|
||||
action: AuthAction.CREATE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
|
||||
},
|
||||
{
|
||||
resource: Resource.FLASH,
|
||||
action: AuthAction.DELETE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
|
||||
},
|
||||
|
||||
// INFO permissions (system information)
|
||||
{
|
||||
resource: Resource.INFO,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
|
||||
// LOGS permissions
|
||||
{
|
||||
resource: Resource.LOGS,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
|
||||
// ME permissions (current user info)
|
||||
{
|
||||
resource: Resource.ME,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT, Role.GUEST],
|
||||
deniedRoles: [],
|
||||
},
|
||||
|
||||
// NOTIFICATIONS permissions
|
||||
{
|
||||
resource: Resource.NOTIFICATIONS,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
|
||||
// Other read-only resources for VIEWER
|
||||
{
|
||||
resource: Resource.DISK,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.DISPLAY,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.ONLINE,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.OWNER,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.REGISTRATION,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.SERVERS,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.SERVICES,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.SHARE,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.VARS,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.CUSTOMIZATIONS,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.ACTIVATION_CODE,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
|
||||
// CONNECT special permission for remote access
|
||||
{
|
||||
resource: Resource.CONNECT__REMOTE_ACCESS,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.CONNECT__REMOTE_ACCESS,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.CONNECT],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST],
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ resource, action, allowedRoles, deniedRoles }) => {
|
||||
describe(`${resource} - ${action}`, () => {
|
||||
let enforcer: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
enforcer = await newEnforcer(model, adapter);
|
||||
});
|
||||
|
||||
allowedRoles.forEach((role) => {
|
||||
it(`should allow ${role} to ${action} ${resource}`, async () => {
|
||||
const result = await enforcer.enforce(role, resource, action);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
deniedRoles.forEach((role) => {
|
||||
it(`should deny ${role} to ${action} ${resource}`, async () => {
|
||||
const result = await enforcer.enforce(role, resource, action);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Action matching and normalization', () => {
|
||||
let enforcer: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
enforcer = await newEnforcer(model, adapter);
|
||||
});
|
||||
|
||||
it('should match actions exactly as stored (uppercase)', async () => {
|
||||
// Our policies store actions as uppercase (e.g., 'READ_ANY')
|
||||
// The matcher now requires exact matching for security
|
||||
|
||||
// Uppercase actions should work
|
||||
const adminUpperResult = await enforcer.enforce(
|
||||
Role.ADMIN,
|
||||
Resource.DOCKER,
|
||||
AuthAction.READ_ANY
|
||||
);
|
||||
expect(adminUpperResult).toBe(true);
|
||||
|
||||
const viewerUpperResult = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.DOCKER,
|
||||
AuthAction.READ_ANY
|
||||
);
|
||||
expect(viewerUpperResult).toBe(true);
|
||||
|
||||
// For non-wildcard roles, lowercase actions won't match
|
||||
const viewerLowerResult = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, 'read:any');
|
||||
expect(viewerLowerResult).toBe(false);
|
||||
|
||||
// Mixed case won't match for VIEWER either
|
||||
const viewerMixedResult = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, 'Read_Any');
|
||||
expect(viewerMixedResult).toBe(false);
|
||||
|
||||
// GUEST also requires exact lowercase
|
||||
const guestUpperResult = await enforcer.enforce(Role.GUEST, Resource.ME, 'READ:ANY');
|
||||
expect(guestUpperResult).toBe(false);
|
||||
|
||||
const guestLowerResult = await enforcer.enforce(
|
||||
Role.GUEST,
|
||||
Resource.ME,
|
||||
AuthAction.READ_ANY
|
||||
);
|
||||
expect(guestLowerResult).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow wildcard actions for ADMIN regardless of case', async () => {
|
||||
// ADMIN has wildcard permissions (*, *, *) which match any action
|
||||
const adminWildcardActions = [
|
||||
'read:any',
|
||||
'create:any',
|
||||
'update:any',
|
||||
'delete:any',
|
||||
'READ:ANY', // Even uppercase works due to wildcard
|
||||
'ANYTHING', // Any action works due to wildcard
|
||||
];
|
||||
|
||||
for (const action of adminWildcardActions) {
|
||||
const result = await enforcer.enforce(Role.ADMIN, Resource.DOCKER, action);
|
||||
expect(result).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should NOT match different actions even with correct case', async () => {
|
||||
// VIEWER should not be able to UPDATE even with correct lowercase
|
||||
const result = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, AuthAction.UPDATE_ANY);
|
||||
expect(result).toBe(false);
|
||||
|
||||
// VIEWER should not be able to DELETE
|
||||
const deleteResult = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.DOCKER,
|
||||
AuthAction.DELETE_ANY
|
||||
);
|
||||
expect(deleteResult).toBe(false);
|
||||
|
||||
// VIEWER should not be able to CREATE
|
||||
const createResult = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.DOCKER,
|
||||
AuthAction.CREATE_ANY
|
||||
);
|
||||
expect(createResult).toBe(false);
|
||||
});
|
||||
|
||||
it('should ensure actions are normalized when stored', async () => {
|
||||
// This test documents that our auth service normalizes actions to uppercase
|
||||
// when syncing permissions, ensuring consistency
|
||||
|
||||
// The BASE_POLICY uses AuthAction.READ_ANY which is 'READ_ANY' (uppercase)
|
||||
expect(BASE_POLICY).toContain('READ_ANY');
|
||||
expect(BASE_POLICY).not.toContain('read:any');
|
||||
|
||||
// All our stored policies should be uppercase
|
||||
const policies = await enforcer.getPolicy();
|
||||
for (const policy of policies) {
|
||||
const action = policy[2]; // Third element is the action
|
||||
if (action && action !== '*') {
|
||||
// All non-wildcard actions should be uppercase
|
||||
expect(action).toBe(action.toUpperCase());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Wildcard permissions', () => {
|
||||
let enforcer: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
enforcer = await newEnforcer(model, adapter);
|
||||
});
|
||||
|
||||
it('should allow ADMIN wildcard access to all resources and actions', async () => {
|
||||
const resources = Object.values(Resource);
|
||||
const actions = [
|
||||
AuthAction.READ_ANY,
|
||||
AuthAction.CREATE_ANY,
|
||||
AuthAction.UPDATE_ANY,
|
||||
AuthAction.DELETE_ANY,
|
||||
];
|
||||
|
||||
for (const resource of resources) {
|
||||
for (const action of actions) {
|
||||
const result = await enforcer.enforce(Role.ADMIN, resource, action);
|
||||
expect(result).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow CONNECT read access to most resources but NOT API_KEY', async () => {
|
||||
const resources = Object.values(Resource).filter(
|
||||
(r) => r !== Resource.CONNECT__REMOTE_ACCESS && r !== Resource.API_KEY
|
||||
);
|
||||
|
||||
for (const resource of resources) {
|
||||
// Should be able to read most resources
|
||||
const readResult = await enforcer.enforce(Role.CONNECT, resource, AuthAction.READ_ANY);
|
||||
expect(readResult).toBe(true);
|
||||
|
||||
// Should NOT be able to write (except CONNECT__REMOTE_ACCESS)
|
||||
const updateResult = await enforcer.enforce(
|
||||
Role.CONNECT,
|
||||
resource,
|
||||
AuthAction.UPDATE_ANY
|
||||
);
|
||||
expect(updateResult).toBe(false);
|
||||
}
|
||||
|
||||
// CONNECT should NOT be able to read API_KEY
|
||||
const apiKeyRead = await enforcer.enforce(
|
||||
Role.CONNECT,
|
||||
Resource.API_KEY,
|
||||
AuthAction.READ_ANY
|
||||
);
|
||||
expect(apiKeyRead).toBe(false);
|
||||
|
||||
// CONNECT should NOT be able to perform any action on API_KEY
|
||||
const apiKeyCreate = await enforcer.enforce(
|
||||
Role.CONNECT,
|
||||
Resource.API_KEY,
|
||||
AuthAction.CREATE_ANY
|
||||
);
|
||||
expect(apiKeyCreate).toBe(false);
|
||||
const apiKeyUpdate = await enforcer.enforce(
|
||||
Role.CONNECT,
|
||||
Resource.API_KEY,
|
||||
AuthAction.UPDATE_ANY
|
||||
);
|
||||
expect(apiKeyUpdate).toBe(false);
|
||||
const apiKeyDelete = await enforcer.enforce(
|
||||
Role.CONNECT,
|
||||
Resource.API_KEY,
|
||||
AuthAction.DELETE_ANY
|
||||
);
|
||||
expect(apiKeyDelete).toBe(false);
|
||||
|
||||
// Special case: CONNECT can update CONNECT__REMOTE_ACCESS
|
||||
const remoteAccessUpdate = await enforcer.enforce(
|
||||
Role.CONNECT,
|
||||
Resource.CONNECT__REMOTE_ACCESS,
|
||||
AuthAction.UPDATE_ANY
|
||||
);
|
||||
expect(remoteAccessUpdate).toBe(true);
|
||||
});
|
||||
|
||||
it('should explicitly deny CONNECT role from accessing API_KEY to prevent secret exposure', async () => {
|
||||
// CONNECT should NOT be able to read API_KEY (which would expose secrets)
|
||||
const apiKeyRead = await enforcer.enforce(
|
||||
Role.CONNECT,
|
||||
Resource.API_KEY,
|
||||
AuthAction.READ_ANY
|
||||
);
|
||||
expect(apiKeyRead).toBe(false);
|
||||
|
||||
// Verify all API_KEY operations are denied for CONNECT
|
||||
const actions = ['create:any', 'read:any', 'update:any', 'delete:any'];
|
||||
for (const action of actions) {
|
||||
const result = await enforcer.enforce(Role.CONNECT, Resource.API_KEY, action);
|
||||
expect(result).toBe(false);
|
||||
}
|
||||
|
||||
// Verify ADMIN can still access API_KEY
|
||||
const adminApiKeyRead = await enforcer.enforce(
|
||||
Role.ADMIN,
|
||||
Resource.API_KEY,
|
||||
AuthAction.READ_ANY
|
||||
);
|
||||
expect(adminApiKeyRead).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role inheritance', () => {
|
||||
let enforcer: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
enforcer = await newEnforcer(model, adapter);
|
||||
});
|
||||
|
||||
it('should inherit GUEST permissions for VIEWER', async () => {
|
||||
// VIEWER inherits from GUEST, so should have ME access
|
||||
const result = await enforcer.enforce(Role.VIEWER, Resource.ME, AuthAction.READ_ANY);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should inherit GUEST permissions for CONNECT', async () => {
|
||||
// CONNECT inherits from GUEST, so should have ME access
|
||||
const result = await enforcer.enforce(Role.CONNECT, Resource.ME, AuthAction.READ_ANY);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should inherit GUEST permissions for ADMIN', async () => {
|
||||
// ADMIN inherits from GUEST, so should have ME access
|
||||
const result = await enforcer.enforce(Role.ADMIN, Resource.ME, AuthAction.READ_ANY);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases and security', () => {
|
||||
it('should deny access with empty action', async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
const result = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, '');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should deny access with empty resource', async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
const result = await enforcer.enforce(Role.VIEWER, '', AuthAction.READ_ANY);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should deny access with undefined role', async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
const result = await enforcer.enforce(
|
||||
'UNDEFINED_ROLE',
|
||||
Resource.DOCKER,
|
||||
AuthAction.READ_ANY
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should deny access with malformed action', async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
const malformedActions = [
|
||||
'read', // Missing possession
|
||||
':any', // Missing verb
|
||||
'read:', // Empty possession
|
||||
'read:own', // Different possession format
|
||||
'READ', // Uppercase without possession
|
||||
];
|
||||
|
||||
for (const action of malformedActions) {
|
||||
const result = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, action);
|
||||
expect(result).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
147
api/src/unraid-api/auth/casbin/policy.spec.ts
Normal file
147
api/src/unraid-api/auth/casbin/policy.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { Model as CasbinModel, newEnforcer, StringAdapter } from 'casbin';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CASBIN_MODEL } from '@app/unraid-api/auth/casbin/model.js';
|
||||
import { BASE_POLICY } from '@app/unraid-api/auth/casbin/policy.js';
|
||||
|
||||
describe('Casbin Policy - VIEWER role restrictions', () => {
|
||||
it('should validate matcher does not allow empty policies', async () => {
|
||||
// Test that empty policies don't match everything
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
|
||||
// Test with a policy that has an empty object
|
||||
const emptyPolicy = `p, VIEWER, , ${AuthAction.READ_ANY}`;
|
||||
const adapter = new StringAdapter(emptyPolicy);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
// Empty policy should not match a real resource
|
||||
const canReadApiKey = await enforcer.enforce(Role.VIEWER, Resource.API_KEY, AuthAction.READ_ANY);
|
||||
expect(canReadApiKey).toBe(false);
|
||||
});
|
||||
|
||||
it('should deny VIEWER role access to API_KEY resource', async () => {
|
||||
// Create enforcer with actual policy
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
// Test that VIEWER cannot access API_KEY with any action
|
||||
const canReadApiKey = await enforcer.enforce(Role.VIEWER, Resource.API_KEY, AuthAction.READ_ANY);
|
||||
const canCreateApiKey = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.API_KEY,
|
||||
AuthAction.CREATE_ANY
|
||||
);
|
||||
const canUpdateApiKey = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.API_KEY,
|
||||
AuthAction.UPDATE_ANY
|
||||
);
|
||||
const canDeleteApiKey = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.API_KEY,
|
||||
AuthAction.DELETE_ANY
|
||||
);
|
||||
|
||||
expect(canReadApiKey).toBe(false);
|
||||
expect(canCreateApiKey).toBe(false);
|
||||
expect(canUpdateApiKey).toBe(false);
|
||||
expect(canDeleteApiKey).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow VIEWER role access to other resources', async () => {
|
||||
// Create enforcer with actual policy
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
// Test that VIEWER can read other resources
|
||||
const canReadDocker = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, AuthAction.READ_ANY);
|
||||
const canReadArray = await enforcer.enforce(Role.VIEWER, Resource.ARRAY, AuthAction.READ_ANY);
|
||||
const canReadConfig = await enforcer.enforce(Role.VIEWER, Resource.CONFIG, AuthAction.READ_ANY);
|
||||
const canReadVms = await enforcer.enforce(Role.VIEWER, Resource.VMS, AuthAction.READ_ANY);
|
||||
|
||||
expect(canReadDocker).toBe(true);
|
||||
expect(canReadArray).toBe(true);
|
||||
expect(canReadConfig).toBe(true);
|
||||
expect(canReadVms).toBe(true);
|
||||
|
||||
// But VIEWER cannot write to these resources
|
||||
const canUpdateDocker = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.DOCKER,
|
||||
AuthAction.UPDATE_ANY
|
||||
);
|
||||
const canDeleteArray = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.ARRAY,
|
||||
AuthAction.DELETE_ANY
|
||||
);
|
||||
|
||||
expect(canUpdateDocker).toBe(false);
|
||||
expect(canDeleteArray).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow ADMIN role full access to API_KEY resource', async () => {
|
||||
// Create enforcer with actual policy
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
// Test that ADMIN can access API_KEY with all actions
|
||||
const canReadApiKey = await enforcer.enforce(Role.ADMIN, Resource.API_KEY, AuthAction.READ_ANY);
|
||||
const canCreateApiKey = await enforcer.enforce(
|
||||
Role.ADMIN,
|
||||
Resource.API_KEY,
|
||||
AuthAction.CREATE_ANY
|
||||
);
|
||||
const canUpdateApiKey = await enforcer.enforce(
|
||||
Role.ADMIN,
|
||||
Resource.API_KEY,
|
||||
AuthAction.UPDATE_ANY
|
||||
);
|
||||
const canDeleteApiKey = await enforcer.enforce(
|
||||
Role.ADMIN,
|
||||
Resource.API_KEY,
|
||||
AuthAction.DELETE_ANY
|
||||
);
|
||||
|
||||
expect(canReadApiKey).toBe(true);
|
||||
expect(canCreateApiKey).toBe(true);
|
||||
expect(canUpdateApiKey).toBe(true);
|
||||
expect(canDeleteApiKey).toBe(true);
|
||||
});
|
||||
|
||||
it('should ensure VIEWER permissions exclude API_KEY in generated policy', () => {
|
||||
// Verify that the generated policy string doesn't contain VIEWER + API_KEY combination
|
||||
expect(BASE_POLICY).toContain(`p, ${Role.VIEWER}, ${Resource.DOCKER}, ${AuthAction.READ_ANY}`);
|
||||
expect(BASE_POLICY).toContain(`p, ${Role.VIEWER}, ${Resource.ARRAY}, ${AuthAction.READ_ANY}`);
|
||||
expect(BASE_POLICY).not.toContain(
|
||||
`p, ${Role.VIEWER}, ${Resource.API_KEY}, ${AuthAction.READ_ANY}`
|
||||
);
|
||||
|
||||
// Count VIEWER permissions - should be total resources minus API_KEY
|
||||
const viewerPermissionLines = BASE_POLICY.split('\n').filter((line) =>
|
||||
line.startsWith(`p, ${Role.VIEWER},`)
|
||||
);
|
||||
const totalResources = Object.values(Resource).length;
|
||||
expect(viewerPermissionLines.length).toBe(totalResources - 1); // All resources except API_KEY
|
||||
});
|
||||
|
||||
it('should inherit GUEST permissions for VIEWER role', async () => {
|
||||
// Create enforcer with actual policy
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
// VIEWER inherits from GUEST, so should have access to ME resource
|
||||
const canReadMe = await enforcer.enforce(Role.VIEWER, Resource.ME, AuthAction.READ_ANY);
|
||||
expect(canReadMe).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,26 @@
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction } from 'nest-authz';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
|
||||
// Generate VIEWER permissions for all resources except API_KEY
|
||||
const viewerPermissions = Object.values(Resource)
|
||||
.filter((resource) => resource !== Resource.API_KEY)
|
||||
.map((resource) => `p, ${Role.VIEWER}, ${resource}, ${AuthAction.READ_ANY}`)
|
||||
.join('\n');
|
||||
|
||||
export const BASE_POLICY = `
|
||||
# Admin permissions
|
||||
# Admin permissions - full access
|
||||
p, ${Role.ADMIN}, *, *
|
||||
|
||||
# Connect Permissions
|
||||
p, ${Role.CONNECT}, *, ${AuthAction.READ_ANY}
|
||||
# Connect permissions - inherits from VIEWER plus can manage remote access
|
||||
p, ${Role.CONNECT}, ${Resource.CONNECT__REMOTE_ACCESS}, ${AuthAction.UPDATE_ANY}
|
||||
|
||||
# Guest permissions
|
||||
# Guest permissions - basic profile access
|
||||
p, ${Role.GUEST}, ${Resource.ME}, ${AuthAction.READ_ANY}
|
||||
|
||||
# Viewer permissions - read-only access to all resources except API_KEY
|
||||
${viewerPermissions}
|
||||
|
||||
# Role inheritance
|
||||
g, ${Role.ADMIN}, ${Role.GUEST}
|
||||
g, ${Role.CONNECT}, ${Role.GUEST}
|
||||
g, ${Role.CONNECT}, ${Role.VIEWER}
|
||||
g, ${Role.VIEWER}, ${Role.GUEST}
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { readdir, readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { fileExists } from '@app/core/utils/files/file-exists.js';
|
||||
@@ -9,7 +9,7 @@ import { batchProcess } from '@app/utils.js';
|
||||
/** token for dependency injection of a session cookie options object */
|
||||
export const SESSION_COOKIE_CONFIG = 'SESSION_COOKIE_CONFIG';
|
||||
|
||||
type SessionCookieConfig = {
|
||||
export type SessionCookieConfig = {
|
||||
namePrefix: string;
|
||||
sessionDir: string;
|
||||
secure: boolean;
|
||||
@@ -68,13 +68,17 @@ export class CookieService {
|
||||
}
|
||||
try {
|
||||
const sessionData = await readFile(sessionFile, 'ascii');
|
||||
return sessionData.includes('unraid_login') && sessionData.includes('unraid_user');
|
||||
return this.isSessionValid(sessionData);
|
||||
} catch (e) {
|
||||
this.logger.error(e, 'Error reading session file');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private isSessionValid(sessionData: string): boolean {
|
||||
return sessionData.includes('unraid_login') && sessionData.includes('unraid_user');
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a session id, returns the full path to the session file on disk.
|
||||
*
|
||||
@@ -91,4 +95,33 @@ export class CookieService {
|
||||
const sanitizedSessionId = sessionId.replace(/[^a-zA-Z0-9]/g, '');
|
||||
return join(this.opts.sessionDir, `sess_${sanitizedSessionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the active session id, if any.
|
||||
* @returns the active session id, if any, or null if no active session is found.
|
||||
*/
|
||||
async getActiveSession(): Promise<string | null> {
|
||||
let sessionFiles: string[] = [];
|
||||
try {
|
||||
sessionFiles = await readdir(this.opts.sessionDir);
|
||||
} catch (e) {
|
||||
this.logger.warn(e, 'Error reading session directory');
|
||||
return null;
|
||||
}
|
||||
for (const sessionFile of sessionFiles) {
|
||||
if (!sessionFile.startsWith('sess_')) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const sessionData = await readFile(join(this.opts.sessionDir, sessionFile), 'ascii');
|
||||
if (this.isSessionValid(sessionData)) {
|
||||
return sessionFile.replace('sess_', '');
|
||||
}
|
||||
} catch {
|
||||
// Ignore unreadable files and continue scanning
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
21
api/src/unraid-api/auth/local-session-lifecycle.service.ts
Normal file
21
api/src/unraid-api/auth/local-session-lifecycle.service.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import { LocalSessionService } from '@app/unraid-api/auth/local-session.service.js';
|
||||
|
||||
/**
|
||||
* Service for managing the lifecycle of the local session.
|
||||
*
|
||||
* Used for tying the local session's lifecycle to the API's life, rather
|
||||
* than the LocalSessionService's lifecycle, since it may also be used by
|
||||
* other applications, like the CLI.
|
||||
*
|
||||
* This service is only used in the API, and not in the CLI.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LocalSessionLifecycleService implements OnModuleInit {
|
||||
constructor(private readonly localSessionService: LocalSessionService) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.localSessionService.generateLocalSession();
|
||||
}
|
||||
}
|
||||
97
api/src/unraid-api/auth/local-session.service.ts
Normal file
97
api/src/unraid-api/auth/local-session.service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { randomBytes, timingSafeEqual } from 'crypto';
|
||||
import { chmod, mkdir, readFile, unlink, writeFile } from 'fs/promises';
|
||||
import { dirname } from 'path';
|
||||
|
||||
import { PATHS_LOCAL_SESSION_FILE } from '@app/environment.js';
|
||||
|
||||
/**
|
||||
* Service that manages a local session file for internal CLI/system authentication.
|
||||
* Creates a secure token on startup that can be used for local system operations.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LocalSessionService {
|
||||
private readonly logger = new Logger(LocalSessionService.name);
|
||||
private sessionToken: string | null = null;
|
||||
private static readonly SESSION_FILE_PATH = PATHS_LOCAL_SESSION_FILE;
|
||||
|
||||
/**
|
||||
* Generate a secure local session token and write it to file
|
||||
*/
|
||||
async generateLocalSession(): Promise<void> {
|
||||
// Generate a cryptographically secure random token
|
||||
this.sessionToken = randomBytes(32).toString('hex');
|
||||
|
||||
try {
|
||||
// Ensure directory exists
|
||||
await mkdir(dirname(LocalSessionService.getSessionFilePath()), { recursive: true });
|
||||
|
||||
// Write token to file
|
||||
await writeFile(LocalSessionService.getSessionFilePath(), this.sessionToken, {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600, // Owner read/write only
|
||||
});
|
||||
|
||||
// Ensure proper permissions (redundant but explicit)
|
||||
// Check if file exists first to handle race conditions in test environments
|
||||
await chmod(LocalSessionService.getSessionFilePath(), 0o600).catch((error) => {
|
||||
this.logger.warn(error, 'Failed to set permissions on local session file');
|
||||
});
|
||||
|
||||
this.logger.debug(`Local session written to ${LocalSessionService.getSessionFilePath()}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to write local session: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and return the current local session token from file
|
||||
*/
|
||||
public async getLocalSession(): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(LocalSessionService.getSessionFilePath(), 'utf-8');
|
||||
} catch (error) {
|
||||
this.logger.warn(error, 'Local session file not found or not readable');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a given token matches the current local session
|
||||
*/
|
||||
public async validateLocalSession(token: string): Promise<boolean> {
|
||||
// Coerce inputs to strings (or empty string if undefined)
|
||||
const tokenStr = token || '';
|
||||
const currentToken = await this.getLocalSession();
|
||||
const currentTokenStr = currentToken || '';
|
||||
|
||||
// Early return if either is empty
|
||||
if (!tokenStr || !currentTokenStr) return false;
|
||||
|
||||
// Create buffers
|
||||
const tokenBuffer = Buffer.from(tokenStr, 'utf-8');
|
||||
const currentTokenBuffer = Buffer.from(currentTokenStr, 'utf-8');
|
||||
|
||||
// Check length equality first to prevent timingSafeEqual from throwing
|
||||
if (tokenBuffer.length !== currentTokenBuffer.length) return false;
|
||||
|
||||
// Use constant-time comparison to prevent timing attacks
|
||||
return timingSafeEqual(tokenBuffer, currentTokenBuffer);
|
||||
}
|
||||
|
||||
public async deleteLocalSession(): Promise<void> {
|
||||
try {
|
||||
await unlink(LocalSessionService.getSessionFilePath());
|
||||
} catch (error) {
|
||||
this.logger.error(error, 'Error deleting local session file');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file path for the local session (useful for external readers)
|
||||
*/
|
||||
public static getSessionFilePath(): string {
|
||||
return LocalSessionService.SESSION_FILE_PATH;
|
||||
}
|
||||
}
|
||||
46
api/src/unraid-api/auth/local-session.strategy.ts
Normal file
46
api/src/unraid-api/auth/local-session.strategy.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
|
||||
import { Strategy } from 'passport-custom';
|
||||
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { UserAccount } from '@app/unraid-api/graph/user/user.model.js';
|
||||
import { FastifyRequest } from '@app/unraid-api/types/fastify.js';
|
||||
|
||||
/**
|
||||
* Passport strategy for local session authentication.
|
||||
* Validates the x-local-session header for internal CLI/system operations.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LocalSessionStrategy extends PassportStrategy(Strategy, 'local-session') {
|
||||
static readonly key = 'local-session';
|
||||
private readonly logger = new Logger(LocalSessionStrategy.name);
|
||||
|
||||
constructor(private readonly authService: AuthService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async validate(request: FastifyRequest): Promise<UserAccount | null> {
|
||||
try {
|
||||
const localSessionToken = request.headers['x-local-session'] as string;
|
||||
|
||||
if (!localSessionToken) {
|
||||
this.logger.verbose('No local session token found in request headers');
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.verbose('Attempting to validate local session token');
|
||||
const user = await this.authService.validateLocalSession(localSessionToken);
|
||||
|
||||
if (user) {
|
||||
this.logger.verbose(`Local session authenticated user: ${user.name}`);
|
||||
return user;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.logger.verbose(error, `Local session validation failed`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import type { CanonicalInternalClientService } from '@unraid/shared';
|
||||
import { CANONICAL_INTERNAL_CLIENT_TOKEN } from '@unraid/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import {
|
||||
CONNECT_STATUS_QUERY,
|
||||
@@ -40,7 +41,7 @@ describe('ApiReportService', () => {
|
||||
providers: [
|
||||
ApiReportService,
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: CliInternalClientService, useValue: mockInternalClientService },
|
||||
{ provide: CANONICAL_INTERNAL_CLIENT_TOKEN, useValue: mockInternalClientService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -64,9 +65,13 @@ describe('ApiReportService', () => {
|
||||
uuid: 'test-uuid',
|
||||
},
|
||||
versions: {
|
||||
unraid: '6.12.0',
|
||||
kernel: '5.19.17',
|
||||
openssl: '3.0.8',
|
||||
core: {
|
||||
unraid: '6.12.0',
|
||||
kernel: '5.19.17',
|
||||
},
|
||||
packages: {
|
||||
openssl: '3.0.8',
|
||||
},
|
||||
},
|
||||
},
|
||||
config: {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { access, readFile, unlink, writeFile } from 'fs/promises';
|
||||
|
||||
import type { CanonicalInternalClientService } from '@unraid/shared';
|
||||
import { CANONICAL_INTERNAL_CLIENT_TOKEN } from '@unraid/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DeveloperToolsService } from '@app/unraid-api/cli/developer/developer-tools.service.js';
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
|
||||
|
||||
@@ -15,7 +16,7 @@ describe('DeveloperToolsService', () => {
|
||||
let service: DeveloperToolsService;
|
||||
let logService: LogService;
|
||||
let restartCommand: RestartCommand;
|
||||
let internalClient: CliInternalClientService;
|
||||
let internalClient: CanonicalInternalClientService;
|
||||
|
||||
const mockClient = {
|
||||
mutate: vi.fn(),
|
||||
@@ -42,7 +43,7 @@ describe('DeveloperToolsService', () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CliInternalClientService,
|
||||
provide: CANONICAL_INTERNAL_CLIENT_TOKEN,
|
||||
useValue: {
|
||||
getClient: vi.fn().mockResolvedValue(mockClient),
|
||||
},
|
||||
@@ -53,7 +54,7 @@ describe('DeveloperToolsService', () => {
|
||||
service = module.get<DeveloperToolsService>(DeveloperToolsService);
|
||||
logService = module.get<LogService>(LogService);
|
||||
restartCommand = module.get<RestartCommand>(RestartCommand);
|
||||
internalClient = module.get<CliInternalClientService>(CliInternalClientService);
|
||||
internalClient = module.get<CanonicalInternalClientService>(CANONICAL_INTERNAL_CLIENT_TOKEN);
|
||||
});
|
||||
|
||||
describe('setSandboxMode', () => {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import type { ApiKeyService } from '@unraid/shared/services/api-key.js';
|
||||
import { Role } from '@unraid/shared/graphql.model.js';
|
||||
import { API_KEY_SERVICE_TOKEN } from '@unraid/shared/tokens.js';
|
||||
|
||||
/**
|
||||
* Service that creates and manages the admin API key used by CLI commands.
|
||||
* Uses the standard API key storage location via helper methods in ApiKeyService.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminKeyService implements OnModuleInit {
|
||||
private readonly logger = new Logger(AdminKeyService.name);
|
||||
private static readonly ADMIN_KEY_NAME = 'CliInternal';
|
||||
private static readonly ADMIN_KEY_DESCRIPTION =
|
||||
'Internal admin API key used by CLI commands for system operations';
|
||||
|
||||
constructor(
|
||||
@Inject(API_KEY_SERVICE_TOKEN)
|
||||
private readonly apiKeyService: ApiKeyService
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
await this.getOrCreateLocalAdminKey();
|
||||
this.logger.log('Admin API key initialized successfully');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize admin API key:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates a local admin API key for CLI operations.
|
||||
* Uses the standard API key storage location.
|
||||
*/
|
||||
public async getOrCreateLocalAdminKey(): Promise<string> {
|
||||
return this.apiKeyService.ensureKey({
|
||||
name: AdminKeyService.ADMIN_KEY_NAME,
|
||||
description: AdminKeyService.ADMIN_KEY_DESCRIPTION,
|
||||
roles: [Role.ADMIN], // Full admin privileges for CLI operations
|
||||
legacyNames: ['CLI', 'Internal', 'CliAdmin'], // Clean up old keys
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import type { CanonicalInternalClientService } from '@unraid/shared';
|
||||
import { CANONICAL_INTERNAL_CLIENT_TOKEN } from '@unraid/shared';
|
||||
|
||||
import type { ConnectStatusQuery, SystemReportQuery } from '@app/unraid-api/cli/generated/graphql.js';
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import {
|
||||
CONNECT_STATUS_QUERY,
|
||||
@@ -60,7 +62,8 @@ export interface ApiReportData {
|
||||
@Injectable()
|
||||
export class ApiReportService {
|
||||
constructor(
|
||||
private readonly internalClient: CliInternalClientService,
|
||||
@Inject(CANONICAL_INTERNAL_CLIENT_TOKEN)
|
||||
private readonly internalClient: CanonicalInternalClientService,
|
||||
private readonly logger: LogService
|
||||
) {}
|
||||
|
||||
@@ -82,7 +85,7 @@ export class ApiReportService {
|
||||
? {
|
||||
id: systemData.info.system.uuid,
|
||||
name: systemData.server?.name || 'Unknown',
|
||||
version: systemData.info.versions.unraid || 'Unknown',
|
||||
version: systemData.info.versions.core.unraid || 'Unknown',
|
||||
machineId: 'REDACTED',
|
||||
manufacturer: systemData.info.system.manufacturer,
|
||||
model: systemData.info.system.model,
|
||||
@@ -135,7 +138,7 @@ export class ApiReportService {
|
||||
});
|
||||
}
|
||||
|
||||
const client = await this.internalClient.getClient();
|
||||
const client = await this.internalClient.getClient({ enableSubscriptions: false });
|
||||
|
||||
// Query system data
|
||||
let systemResult: { data: SystemReportQuery } | null = null;
|
||||
@@ -190,7 +193,7 @@ export class ApiReportService {
|
||||
|
||||
return this.createApiReportData({
|
||||
apiRunning,
|
||||
systemData: systemResult.data,
|
||||
systemData: systemResult?.data,
|
||||
connectData,
|
||||
servicesData,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthActionVerb } from 'nest-authz';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { Command, CommandRunner, InquirerService, Option } from 'nest-commander';
|
||||
|
||||
import type { DeleteApiKeyAnswers } from '@app/unraid-api/cli/apikey/delete-api-key.questions.js';
|
||||
@@ -75,7 +74,7 @@ export class ApiKeyCommand extends CommandRunner {
|
||||
flags: '-p, --permissions <permissions>',
|
||||
description: `Comma separated list of permissions to assign to the key (in the form of "resource:action")
|
||||
RESOURCES: ${Object.values(Resource).join(', ')}
|
||||
ACTIONS: ${Object.values(AuthActionVerb).join(', ')}`,
|
||||
ACTIONS: ${Object.values(AuthAction).join(', ')}`,
|
||||
})
|
||||
parsePermissions(permissions: string): Array<Permission> {
|
||||
return this.apiKeyService.convertPermissionsStringArrayToPermissions(
|
||||
|
||||
@@ -2,9 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
|
||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
|
||||
import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js';
|
||||
@@ -23,15 +21,7 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u
|
||||
PluginCliModule.register(),
|
||||
UnraidFileModifierModule,
|
||||
],
|
||||
providers: [
|
||||
LogService,
|
||||
PM2Service,
|
||||
ApiKeyService,
|
||||
DependencyService,
|
||||
AdminKeyService,
|
||||
ApiReportService,
|
||||
CliInternalClientService,
|
||||
],
|
||||
exports: [ApiReportService, LogService, ApiKeyService, CliInternalClientService],
|
||||
providers: [LogService, PM2Service, ApiKeyService, DependencyService, ApiReportService],
|
||||
exports: [ApiReportService, LogService, ApiKeyService],
|
||||
})
|
||||
export class CliServicesModule {}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { INTERNAL_CLIENT_SERVICE_TOKEN } from '@unraid/shared';
|
||||
import { CANONICAL_INTERNAL_CLIENT_TOKEN, INTERNAL_CLIENT_FACTORY_TOKEN } from '@unraid/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
|
||||
import { CliServicesModule } from '@app/unraid-api/cli/cli-services.module.js';
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { InternalGraphQLClientFactory } from '@app/unraid-api/shared/internal-graphql-client.factory.js';
|
||||
|
||||
describe('CliServicesModule', () => {
|
||||
@@ -26,29 +24,23 @@ describe('CliServicesModule', () => {
|
||||
expect(module).toBeDefined();
|
||||
});
|
||||
|
||||
it('should provide CliInternalClientService', () => {
|
||||
const service = module.get(CliInternalClientService);
|
||||
it('should provide CanonicalInternalClient', () => {
|
||||
const service = module.get(CANONICAL_INTERNAL_CLIENT_TOKEN);
|
||||
expect(service).toBeDefined();
|
||||
expect(service).toBeInstanceOf(CliInternalClientService);
|
||||
});
|
||||
|
||||
it('should provide AdminKeyService', () => {
|
||||
const service = module.get(AdminKeyService);
|
||||
expect(service).toBeDefined();
|
||||
expect(service).toBeInstanceOf(AdminKeyService);
|
||||
expect(service.getClient).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should provide InternalGraphQLClientFactory via token', () => {
|
||||
const factory = module.get(INTERNAL_CLIENT_SERVICE_TOKEN);
|
||||
const factory = module.get(INTERNAL_CLIENT_FACTORY_TOKEN);
|
||||
expect(factory).toBeDefined();
|
||||
expect(factory).toBeInstanceOf(InternalGraphQLClientFactory);
|
||||
});
|
||||
|
||||
describe('CliInternalClientService dependencies', () => {
|
||||
describe('CanonicalInternalClient dependencies', () => {
|
||||
it('should have all required dependencies available', () => {
|
||||
// This test ensures that CliInternalClientService can be instantiated
|
||||
// This test ensures that CanonicalInternalClient can be instantiated
|
||||
// with all its dependencies properly resolved
|
||||
const service = module.get(CliInternalClientService);
|
||||
const service = module.get(CANONICAL_INTERNAL_CLIENT_TOKEN);
|
||||
expect(service).toBeDefined();
|
||||
|
||||
// Verify the service has its dependencies injected
|
||||
@@ -59,16 +51,9 @@ describe('CliServicesModule', () => {
|
||||
|
||||
it('should resolve InternalGraphQLClientFactory dependency via token', () => {
|
||||
// Explicitly test that the factory is available in the module context via token
|
||||
const factory = module.get(INTERNAL_CLIENT_SERVICE_TOKEN);
|
||||
const factory = module.get(INTERNAL_CLIENT_FACTORY_TOKEN);
|
||||
expect(factory).toBeDefined();
|
||||
expect(factory.createClient).toBeDefined();
|
||||
});
|
||||
|
||||
it('should resolve AdminKeyService dependency', () => {
|
||||
// Explicitly test that AdminKeyService is available in the module context
|
||||
const adminKeyService = module.get(AdminKeyService);
|
||||
expect(adminKeyService).toBeDefined();
|
||||
expect(adminKeyService.getOrCreateLocalAdminKey).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
|
||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
||||
import { AddApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/add-api-key.questions.js';
|
||||
import { ApiKeyCommand } from '@app/unraid-api/cli/apikey/api-key.command.js';
|
||||
@@ -12,7 +11,6 @@ import { ConfigCommand } from '@app/unraid-api/cli/config.command.js';
|
||||
import { DeveloperToolsService } from '@app/unraid-api/cli/developer/developer-tools.service.js';
|
||||
import { DeveloperCommand } from '@app/unraid-api/cli/developer/developer.command.js';
|
||||
import { DeveloperQuestions } from '@app/unraid-api/cli/developer/developer.questions.js';
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { LogsCommand } from '@app/unraid-api/cli/logs.command.js';
|
||||
import {
|
||||
@@ -69,9 +67,7 @@ const DEFAULT_PROVIDERS = [
|
||||
PM2Service,
|
||||
ApiKeyService,
|
||||
DependencyService,
|
||||
AdminKeyService,
|
||||
ApiReportService,
|
||||
CliInternalClientService,
|
||||
] as const;
|
||||
|
||||
@Module({
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { access, readFile, unlink, writeFile } from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import type { CanonicalInternalClientService } from '@unraid/shared';
|
||||
import { CANONICAL_INTERNAL_CLIENT_TOKEN } from '@unraid/shared';
|
||||
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { UPDATE_SANDBOX_MUTATION } from '@app/unraid-api/cli/queries/developer.mutation.js';
|
||||
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
|
||||
@@ -52,12 +54,13 @@ unraid-dev-modal-test {
|
||||
constructor(
|
||||
private readonly logger: LogService,
|
||||
private readonly restartCommand: RestartCommand,
|
||||
private readonly internalClient: CliInternalClientService
|
||||
@Inject(CANONICAL_INTERNAL_CLIENT_TOKEN)
|
||||
private readonly internalClient: CanonicalInternalClientService
|
||||
) {}
|
||||
|
||||
async setSandboxMode(enable: boolean): Promise<void> {
|
||||
try {
|
||||
const client = await this.internalClient.getClient();
|
||||
const client = await this.internalClient.getClient({ enableSubscriptions: false });
|
||||
|
||||
const result = await client.mutate({
|
||||
mutation: UPDATE_SANDBOX_MUTATION,
|
||||
|
||||
@@ -20,7 +20,7 @@ type Documents = {
|
||||
"\n mutation UpdateSandboxSettings($input: JSON!) {\n updateSettings(input: $input) {\n restartRequired\n values\n }\n }\n": typeof types.UpdateSandboxSettingsDocument,
|
||||
"\n query GetPlugins {\n plugins {\n name\n version\n hasApiModule\n hasCliModule\n }\n }\n": typeof types.GetPluginsDocument,
|
||||
"\n query GetSSOUsers {\n settings {\n api {\n ssoSubIds\n }\n }\n }\n": typeof types.GetSsoUsersDocument,
|
||||
"\n query SystemReport {\n info {\n id\n machineId\n system {\n manufacturer\n model\n version\n sku\n serial\n uuid\n }\n versions {\n unraid\n kernel\n openssl\n }\n }\n config {\n id\n valid\n error\n }\n server {\n id\n name\n }\n }\n": typeof types.SystemReportDocument,
|
||||
"\n query SystemReport {\n info {\n id\n machineId\n system {\n manufacturer\n model\n version\n sku\n serial\n uuid\n }\n versions {\n core {\n unraid\n kernel\n }\n packages {\n openssl\n }\n }\n }\n config {\n id\n valid\n error\n }\n server {\n id\n name\n }\n }\n": typeof types.SystemReportDocument,
|
||||
"\n query ConnectStatus {\n connect {\n id\n dynamicRemoteAccess {\n enabledType\n runningType\n error\n }\n }\n }\n": typeof types.ConnectStatusDocument,
|
||||
"\n query Services {\n services {\n id\n name\n online\n uptime {\n timestamp\n }\n version\n }\n }\n": typeof types.ServicesDocument,
|
||||
"\n query ValidateOidcSession($token: String!) {\n validateOidcSession(token: $token) {\n valid\n username\n }\n }\n": typeof types.ValidateOidcSessionDocument,
|
||||
@@ -32,7 +32,7 @@ const documents: Documents = {
|
||||
"\n mutation UpdateSandboxSettings($input: JSON!) {\n updateSettings(input: $input) {\n restartRequired\n values\n }\n }\n": types.UpdateSandboxSettingsDocument,
|
||||
"\n query GetPlugins {\n plugins {\n name\n version\n hasApiModule\n hasCliModule\n }\n }\n": types.GetPluginsDocument,
|
||||
"\n query GetSSOUsers {\n settings {\n api {\n ssoSubIds\n }\n }\n }\n": types.GetSsoUsersDocument,
|
||||
"\n query SystemReport {\n info {\n id\n machineId\n system {\n manufacturer\n model\n version\n sku\n serial\n uuid\n }\n versions {\n unraid\n kernel\n openssl\n }\n }\n config {\n id\n valid\n error\n }\n server {\n id\n name\n }\n }\n": types.SystemReportDocument,
|
||||
"\n query SystemReport {\n info {\n id\n machineId\n system {\n manufacturer\n model\n version\n sku\n serial\n uuid\n }\n versions {\n core {\n unraid\n kernel\n }\n packages {\n openssl\n }\n }\n }\n config {\n id\n valid\n error\n }\n server {\n id\n name\n }\n }\n": types.SystemReportDocument,
|
||||
"\n query ConnectStatus {\n connect {\n id\n dynamicRemoteAccess {\n enabledType\n runningType\n error\n }\n }\n }\n": types.ConnectStatusDocument,
|
||||
"\n query Services {\n services {\n id\n name\n online\n uptime {\n timestamp\n }\n version\n }\n }\n": types.ServicesDocument,
|
||||
"\n query ValidateOidcSession($token: String!) {\n validateOidcSession(token: $token) {\n valid\n username\n }\n }\n": types.ValidateOidcSessionDocument,
|
||||
@@ -79,7 +79,7 @@ export function gql(source: "\n query GetSSOUsers {\n settings {\n
|
||||
/**
|
||||
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function gql(source: "\n query SystemReport {\n info {\n id\n machineId\n system {\n manufacturer\n model\n version\n sku\n serial\n uuid\n }\n versions {\n unraid\n kernel\n openssl\n }\n }\n config {\n id\n valid\n error\n }\n server {\n id\n name\n }\n }\n"): (typeof documents)["\n query SystemReport {\n info {\n id\n machineId\n system {\n manufacturer\n model\n version\n sku\n serial\n uuid\n }\n versions {\n unraid\n kernel\n openssl\n }\n }\n config {\n id\n valid\n error\n }\n server {\n id\n name\n }\n }\n"];
|
||||
export function gql(source: "\n query SystemReport {\n info {\n id\n machineId\n system {\n manufacturer\n model\n version\n sku\n serial\n uuid\n }\n versions {\n core {\n unraid\n kernel\n }\n packages {\n openssl\n }\n }\n }\n config {\n id\n valid\n error\n }\n server {\n id\n name\n }\n }\n"): (typeof documents)["\n query SystemReport {\n info {\n id\n machineId\n system {\n manufacturer\n model\n version\n sku\n serial\n uuid\n }\n versions {\n core {\n unraid\n kernel\n }\n packages {\n openssl\n }\n }\n }\n config {\n id\n valid\n error\n }\n server {\n id\n name\n }\n }\n"];
|
||||
/**
|
||||
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
||||
@@ -120,7 +120,7 @@ export type ActivationCode = {
|
||||
};
|
||||
|
||||
export type AddPermissionInput = {
|
||||
actions: Array<Scalars['String']['input']>;
|
||||
actions: Array<AuthAction>;
|
||||
resource: Resource;
|
||||
};
|
||||
|
||||
@@ -143,24 +143,36 @@ export type ApiKey = Node & {
|
||||
createdAt: Scalars['String']['output'];
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
key: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
permissions: Array<Permission>;
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
export type ApiKeyFormSettings = FormSchema & Node & {
|
||||
__typename?: 'ApiKeyFormSettings';
|
||||
/** The data schema for the API key form */
|
||||
dataSchema: Scalars['JSON']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** The UI schema for the API key form */
|
||||
uiSchema: Scalars['JSON']['output'];
|
||||
/** The current values of the API key form */
|
||||
values: Scalars['JSON']['output'];
|
||||
};
|
||||
|
||||
/** API Key related mutations */
|
||||
export type ApiKeyMutations = {
|
||||
__typename?: 'ApiKeyMutations';
|
||||
/** Add a role to an API key */
|
||||
addRole: Scalars['Boolean']['output'];
|
||||
/** Create an API key */
|
||||
create: ApiKeyWithSecret;
|
||||
create: ApiKey;
|
||||
/** Delete one or more API keys */
|
||||
delete: Scalars['Boolean']['output'];
|
||||
/** Remove a role from an API key */
|
||||
removeRole: Scalars['Boolean']['output'];
|
||||
/** Update an API key */
|
||||
update: ApiKeyWithSecret;
|
||||
update: ApiKey;
|
||||
};
|
||||
|
||||
|
||||
@@ -199,17 +211,6 @@ export type ApiKeyResponse = {
|
||||
valid: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type ApiKeyWithSecret = Node & {
|
||||
__typename?: 'ApiKeyWithSecret';
|
||||
createdAt: Scalars['String']['output'];
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
key: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
permissions: Array<Permission>;
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
export type ArrayCapacity = {
|
||||
__typename?: 'ArrayCapacity';
|
||||
/** Capacity in number of disks */
|
||||
@@ -370,19 +371,24 @@ 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'
|
||||
/** Authentication actions with possession (e.g., create:any, read:own) */
|
||||
export enum AuthAction {
|
||||
/** Create any resource */
|
||||
CREATE_ANY = 'CREATE_ANY',
|
||||
/** Create own resource */
|
||||
CREATE_OWN = 'CREATE_OWN',
|
||||
/** Delete any resource */
|
||||
DELETE_ANY = 'DELETE_ANY',
|
||||
/** Delete own resource */
|
||||
DELETE_OWN = 'DELETE_OWN',
|
||||
/** Read any resource */
|
||||
READ_ANY = 'READ_ANY',
|
||||
/** Read own resource */
|
||||
READ_OWN = 'READ_OWN',
|
||||
/** Update any resource */
|
||||
UPDATE_ANY = 'UPDATE_ANY',
|
||||
/** Update own resource */
|
||||
UPDATE_OWN = 'UPDATE_OWN'
|
||||
}
|
||||
|
||||
/** Operators for authorization rule matching */
|
||||
@@ -399,16 +405,6 @@ export enum AuthorizationRuleMode {
|
||||
OR = 'OR'
|
||||
}
|
||||
|
||||
export type Baseboard = Node & {
|
||||
__typename?: 'Baseboard';
|
||||
assetTag?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
manufacturer: Scalars['String']['output'];
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
version?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Capacity = {
|
||||
__typename?: 'Capacity';
|
||||
/** Free capacity */
|
||||
@@ -419,15 +415,6 @@ export type Capacity = {
|
||||
used: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type Case = Node & {
|
||||
__typename?: 'Case';
|
||||
base64?: Maybe<Scalars['String']['output']>;
|
||||
error?: Maybe<Scalars['String']['output']>;
|
||||
icon?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
url?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Cloud = {
|
||||
__typename?: 'Cloud';
|
||||
allowedOrigins: Array<Scalars['String']['output']>;
|
||||
@@ -539,6 +526,42 @@ export enum ContainerState {
|
||||
RUNNING = 'RUNNING'
|
||||
}
|
||||
|
||||
export type CoreVersions = {
|
||||
__typename?: 'CoreVersions';
|
||||
/** Unraid API version */
|
||||
api?: Maybe<Scalars['String']['output']>;
|
||||
/** Kernel version */
|
||||
kernel?: Maybe<Scalars['String']['output']>;
|
||||
/** Unraid version */
|
||||
unraid?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
/** CPU load for a single core */
|
||||
export type CpuLoad = {
|
||||
__typename?: 'CpuLoad';
|
||||
/** The percentage of time the CPU was idle. */
|
||||
percentIdle: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU spent servicing hardware interrupts. */
|
||||
percentIrq: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU spent on low-priority (niced) user space processes. */
|
||||
percentNice: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU spent in kernel space. */
|
||||
percentSystem: Scalars['Float']['output'];
|
||||
/** The total CPU load on a single core, in percent. */
|
||||
percentTotal: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU spent in user space. */
|
||||
percentUser: Scalars['Float']['output'];
|
||||
};
|
||||
|
||||
export type CpuUtilization = Node & {
|
||||
__typename?: 'CpuUtilization';
|
||||
/** CPU load for each core */
|
||||
cpus: Array<CpuLoad>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Total CPU load in percent */
|
||||
percentTotal: Scalars['Float']['output'];
|
||||
};
|
||||
|
||||
export type CreateApiKeyInput = {
|
||||
description?: InputMaybe<Scalars['String']['input']>;
|
||||
name: Scalars['String']['input'];
|
||||
@@ -569,14 +592,6 @@ export type DeleteRCloneRemoteInput = {
|
||||
name: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type Devices = Node & {
|
||||
__typename?: 'Devices';
|
||||
gpu: Array<Gpu>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
pci: Array<Pci>;
|
||||
usb: Array<Usb>;
|
||||
};
|
||||
|
||||
export type Disk = Node & {
|
||||
__typename?: 'Disk';
|
||||
/** The number of bytes per sector */
|
||||
@@ -653,31 +668,6 @@ export enum DiskSmartStatus {
|
||||
UNKNOWN = 'UNKNOWN'
|
||||
}
|
||||
|
||||
export type Display = Node & {
|
||||
__typename?: 'Display';
|
||||
banner?: Maybe<Scalars['String']['output']>;
|
||||
case?: Maybe<Case>;
|
||||
critical?: Maybe<Scalars['Int']['output']>;
|
||||
dashapps?: Maybe<Scalars['String']['output']>;
|
||||
date?: Maybe<Scalars['String']['output']>;
|
||||
hot?: Maybe<Scalars['Int']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
locale?: Maybe<Scalars['String']['output']>;
|
||||
max?: Maybe<Scalars['Int']['output']>;
|
||||
number?: Maybe<Scalars['String']['output']>;
|
||||
resize?: Maybe<Scalars['Boolean']['output']>;
|
||||
scale?: Maybe<Scalars['Boolean']['output']>;
|
||||
tabs?: Maybe<Scalars['Boolean']['output']>;
|
||||
text?: Maybe<Scalars['Boolean']['output']>;
|
||||
theme?: Maybe<ThemeName>;
|
||||
total?: Maybe<Scalars['Boolean']['output']>;
|
||||
unit?: Maybe<Temperature>;
|
||||
usage?: Maybe<Scalars['Boolean']['output']>;
|
||||
users?: Maybe<Scalars['String']['output']>;
|
||||
warning?: Maybe<Scalars['Int']['output']>;
|
||||
wwn?: Maybe<Scalars['Boolean']['output']>;
|
||||
};
|
||||
|
||||
export type Docker = Node & {
|
||||
__typename?: 'Docker';
|
||||
containers: Array<DockerContainer>;
|
||||
@@ -792,80 +782,293 @@ export type FlashBackupStatus = {
|
||||
status: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type Gpu = Node & {
|
||||
__typename?: 'Gpu';
|
||||
blacklisted: Scalars['Boolean']['output'];
|
||||
class: Scalars['String']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
productid: Scalars['String']['output'];
|
||||
type: Scalars['String']['output'];
|
||||
typeid: Scalars['String']['output'];
|
||||
vendorname: Scalars['String']['output'];
|
||||
export type FormSchema = {
|
||||
/** The data schema for the form */
|
||||
dataSchema: Scalars['JSON']['output'];
|
||||
/** The UI schema for the form */
|
||||
uiSchema: Scalars['JSON']['output'];
|
||||
/** The current values of the form */
|
||||
values: Scalars['JSON']['output'];
|
||||
};
|
||||
|
||||
export type Info = Node & {
|
||||
__typename?: 'Info';
|
||||
/** Count of docker containers */
|
||||
apps: InfoApps;
|
||||
baseboard: Baseboard;
|
||||
/** Motherboard information */
|
||||
baseboard: InfoBaseboard;
|
||||
/** CPU information */
|
||||
cpu: InfoCpu;
|
||||
devices: Devices;
|
||||
display: Display;
|
||||
/** Device information */
|
||||
devices: InfoDevices;
|
||||
/** Display configuration */
|
||||
display: InfoDisplay;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Machine ID */
|
||||
machineId?: Maybe<Scalars['PrefixedID']['output']>;
|
||||
machineId?: Maybe<Scalars['ID']['output']>;
|
||||
/** Memory information */
|
||||
memory: InfoMemory;
|
||||
os: Os;
|
||||
system: System;
|
||||
/** Operating system information */
|
||||
os: InfoOs;
|
||||
/** System information */
|
||||
system: InfoSystem;
|
||||
/** Current server time */
|
||||
time: Scalars['DateTime']['output'];
|
||||
versions: Versions;
|
||||
/** Software versions */
|
||||
versions: InfoVersions;
|
||||
};
|
||||
|
||||
export type InfoApps = Node & {
|
||||
__typename?: 'InfoApps';
|
||||
export type InfoBaseboard = Node & {
|
||||
__typename?: 'InfoBaseboard';
|
||||
/** Motherboard asset tag */
|
||||
assetTag?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** How many docker containers are installed */
|
||||
installed: Scalars['Int']['output'];
|
||||
/** How many docker containers are running */
|
||||
started: Scalars['Int']['output'];
|
||||
/** Motherboard manufacturer */
|
||||
manufacturer?: Maybe<Scalars['String']['output']>;
|
||||
/** Maximum memory capacity in bytes */
|
||||
memMax?: Maybe<Scalars['Float']['output']>;
|
||||
/** Number of memory slots */
|
||||
memSlots?: Maybe<Scalars['Float']['output']>;
|
||||
/** Motherboard model */
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
/** Motherboard serial number */
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
/** Motherboard version */
|
||||
version?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InfoCpu = Node & {
|
||||
__typename?: 'InfoCpu';
|
||||
brand: Scalars['String']['output'];
|
||||
cache: Scalars['JSON']['output'];
|
||||
cores: Scalars['Int']['output'];
|
||||
family: Scalars['String']['output'];
|
||||
flags: Array<Scalars['String']['output']>;
|
||||
/** CPU brand name */
|
||||
brand?: Maybe<Scalars['String']['output']>;
|
||||
/** CPU cache information */
|
||||
cache?: Maybe<Scalars['JSON']['output']>;
|
||||
/** Number of CPU cores */
|
||||
cores?: Maybe<Scalars['Int']['output']>;
|
||||
/** CPU family */
|
||||
family?: Maybe<Scalars['String']['output']>;
|
||||
/** CPU feature flags */
|
||||
flags?: Maybe<Array<Scalars['String']['output']>>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
manufacturer: Scalars['String']['output'];
|
||||
model: Scalars['String']['output'];
|
||||
processors: Scalars['Int']['output'];
|
||||
revision: Scalars['String']['output'];
|
||||
socket: Scalars['String']['output'];
|
||||
speed: Scalars['Float']['output'];
|
||||
speedmax: Scalars['Float']['output'];
|
||||
speedmin: Scalars['Float']['output'];
|
||||
stepping: Scalars['Int']['output'];
|
||||
threads: Scalars['Int']['output'];
|
||||
vendor: Scalars['String']['output'];
|
||||
/** CPU manufacturer */
|
||||
manufacturer?: Maybe<Scalars['String']['output']>;
|
||||
/** CPU model */
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
/** Number of physical processors */
|
||||
processors?: Maybe<Scalars['Int']['output']>;
|
||||
/** CPU revision */
|
||||
revision?: Maybe<Scalars['String']['output']>;
|
||||
/** CPU socket type */
|
||||
socket?: Maybe<Scalars['String']['output']>;
|
||||
/** Current CPU speed in GHz */
|
||||
speed?: Maybe<Scalars['Float']['output']>;
|
||||
/** Maximum CPU speed in GHz */
|
||||
speedmax?: Maybe<Scalars['Float']['output']>;
|
||||
/** Minimum CPU speed in GHz */
|
||||
speedmin?: Maybe<Scalars['Float']['output']>;
|
||||
/** CPU stepping */
|
||||
stepping?: Maybe<Scalars['Int']['output']>;
|
||||
/** Number of CPU threads */
|
||||
threads?: Maybe<Scalars['Int']['output']>;
|
||||
/** CPU vendor */
|
||||
vendor?: Maybe<Scalars['String']['output']>;
|
||||
/** CPU voltage */
|
||||
voltage?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InfoDevices = Node & {
|
||||
__typename?: 'InfoDevices';
|
||||
/** List of GPU devices */
|
||||
gpu?: Maybe<Array<InfoGpu>>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** List of network interfaces */
|
||||
network?: Maybe<Array<InfoNetwork>>;
|
||||
/** List of PCI devices */
|
||||
pci?: Maybe<Array<InfoPci>>;
|
||||
/** List of USB devices */
|
||||
usb?: Maybe<Array<InfoUsb>>;
|
||||
};
|
||||
|
||||
export type InfoDisplay = Node & {
|
||||
__typename?: 'InfoDisplay';
|
||||
/** Case display configuration */
|
||||
case: InfoDisplayCase;
|
||||
/** Critical temperature threshold */
|
||||
critical: Scalars['Int']['output'];
|
||||
/** Hot temperature threshold */
|
||||
hot: Scalars['Int']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Locale setting */
|
||||
locale?: Maybe<Scalars['String']['output']>;
|
||||
/** Maximum temperature threshold */
|
||||
max?: Maybe<Scalars['Int']['output']>;
|
||||
/** Enable UI resize */
|
||||
resize: Scalars['Boolean']['output'];
|
||||
/** Enable UI scaling */
|
||||
scale: Scalars['Boolean']['output'];
|
||||
/** Show tabs in UI */
|
||||
tabs: Scalars['Boolean']['output'];
|
||||
/** Show text labels */
|
||||
text: Scalars['Boolean']['output'];
|
||||
/** UI theme name */
|
||||
theme: ThemeName;
|
||||
/** Show totals */
|
||||
total: Scalars['Boolean']['output'];
|
||||
/** Temperature unit (C or F) */
|
||||
unit: Temperature;
|
||||
/** Show usage statistics */
|
||||
usage: Scalars['Boolean']['output'];
|
||||
/** Warning temperature threshold */
|
||||
warning: Scalars['Int']['output'];
|
||||
/** Show WWN identifiers */
|
||||
wwn: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type InfoDisplayCase = Node & {
|
||||
__typename?: 'InfoDisplayCase';
|
||||
/** Base64 encoded case image */
|
||||
base64: Scalars['String']['output'];
|
||||
/** Error message if any */
|
||||
error: Scalars['String']['output'];
|
||||
/** Case icon identifier */
|
||||
icon: Scalars['String']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Case image URL */
|
||||
url: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type InfoGpu = Node & {
|
||||
__typename?: 'InfoGpu';
|
||||
/** Whether GPU is blacklisted */
|
||||
blacklisted: Scalars['Boolean']['output'];
|
||||
/** Device class */
|
||||
class: Scalars['String']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Product ID */
|
||||
productid: Scalars['String']['output'];
|
||||
/** GPU type/manufacturer */
|
||||
type: Scalars['String']['output'];
|
||||
/** GPU type identifier */
|
||||
typeid: Scalars['String']['output'];
|
||||
/** Vendor name */
|
||||
vendorname?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InfoMemory = Node & {
|
||||
__typename?: 'InfoMemory';
|
||||
active: Scalars['BigInt']['output'];
|
||||
available: Scalars['BigInt']['output'];
|
||||
buffcache: Scalars['BigInt']['output'];
|
||||
free: Scalars['BigInt']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Physical memory layout */
|
||||
layout: Array<MemoryLayout>;
|
||||
max: Scalars['BigInt']['output'];
|
||||
swapfree: Scalars['BigInt']['output'];
|
||||
swaptotal: Scalars['BigInt']['output'];
|
||||
swapused: Scalars['BigInt']['output'];
|
||||
total: Scalars['BigInt']['output'];
|
||||
used: Scalars['BigInt']['output'];
|
||||
};
|
||||
|
||||
export type InfoNetwork = Node & {
|
||||
__typename?: 'InfoNetwork';
|
||||
/** DHCP enabled flag */
|
||||
dhcp?: Maybe<Scalars['Boolean']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Network interface name */
|
||||
iface: Scalars['String']['output'];
|
||||
/** MAC address */
|
||||
mac?: Maybe<Scalars['String']['output']>;
|
||||
/** Network interface model */
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
/** Network speed */
|
||||
speed?: Maybe<Scalars['String']['output']>;
|
||||
/** Network vendor */
|
||||
vendor?: Maybe<Scalars['String']['output']>;
|
||||
/** Virtual interface flag */
|
||||
virtual?: Maybe<Scalars['Boolean']['output']>;
|
||||
};
|
||||
|
||||
export type InfoOs = Node & {
|
||||
__typename?: 'InfoOs';
|
||||
/** OS architecture */
|
||||
arch?: Maybe<Scalars['String']['output']>;
|
||||
/** OS build identifier */
|
||||
build?: Maybe<Scalars['String']['output']>;
|
||||
/** OS codename */
|
||||
codename?: Maybe<Scalars['String']['output']>;
|
||||
/** Linux distribution name */
|
||||
distro?: Maybe<Scalars['String']['output']>;
|
||||
/** Fully qualified domain name */
|
||||
fqdn?: Maybe<Scalars['String']['output']>;
|
||||
/** Hostname */
|
||||
hostname?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Kernel version */
|
||||
kernel?: Maybe<Scalars['String']['output']>;
|
||||
/** OS logo name */
|
||||
logofile?: Maybe<Scalars['String']['output']>;
|
||||
/** Operating system platform */
|
||||
platform?: Maybe<Scalars['String']['output']>;
|
||||
/** OS release version */
|
||||
release?: Maybe<Scalars['String']['output']>;
|
||||
/** OS serial number */
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
/** Service pack version */
|
||||
servicepack?: Maybe<Scalars['String']['output']>;
|
||||
/** OS started via UEFI */
|
||||
uefi?: Maybe<Scalars['Boolean']['output']>;
|
||||
/** Boot time ISO string */
|
||||
uptime?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InfoPci = Node & {
|
||||
__typename?: 'InfoPci';
|
||||
/** Blacklisted status */
|
||||
blacklisted: Scalars['String']['output'];
|
||||
/** Device class */
|
||||
class: Scalars['String']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Product ID */
|
||||
productid: Scalars['String']['output'];
|
||||
/** Product name */
|
||||
productname?: Maybe<Scalars['String']['output']>;
|
||||
/** Device type/manufacturer */
|
||||
type: Scalars['String']['output'];
|
||||
/** Type identifier */
|
||||
typeid: Scalars['String']['output'];
|
||||
/** Vendor ID */
|
||||
vendorid: Scalars['String']['output'];
|
||||
/** Vendor name */
|
||||
vendorname?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InfoSystem = Node & {
|
||||
__typename?: 'InfoSystem';
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** System manufacturer */
|
||||
manufacturer?: Maybe<Scalars['String']['output']>;
|
||||
/** System model */
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
/** System serial number */
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
/** System SKU */
|
||||
sku?: Maybe<Scalars['String']['output']>;
|
||||
/** System UUID */
|
||||
uuid?: Maybe<Scalars['String']['output']>;
|
||||
/** System version */
|
||||
version?: Maybe<Scalars['String']['output']>;
|
||||
/** Virtual machine flag */
|
||||
virtual?: Maybe<Scalars['Boolean']['output']>;
|
||||
};
|
||||
|
||||
export type InfoUsb = Node & {
|
||||
__typename?: 'InfoUsb';
|
||||
/** USB bus number */
|
||||
bus?: Maybe<Scalars['String']['output']>;
|
||||
/** USB device number */
|
||||
device?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** USB device name */
|
||||
name: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type InfoVersions = Node & {
|
||||
__typename?: 'InfoVersions';
|
||||
/** Core system versions */
|
||||
core: CoreVersions;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Software package versions */
|
||||
packages?: Maybe<PackageVersions>;
|
||||
};
|
||||
|
||||
export type InitiateFlashBackupInput = {
|
||||
@@ -911,20 +1114,68 @@ export type LogFileContent = {
|
||||
|
||||
export type MemoryLayout = Node & {
|
||||
__typename?: 'MemoryLayout';
|
||||
/** Memory bank location (e.g., BANK 0) */
|
||||
bank?: Maybe<Scalars['String']['output']>;
|
||||
/** Memory clock speed in MHz */
|
||||
clockSpeed?: Maybe<Scalars['Int']['output']>;
|
||||
/** Form factor (e.g., DIMM, SODIMM) */
|
||||
formFactor?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Memory manufacturer */
|
||||
manufacturer?: Maybe<Scalars['String']['output']>;
|
||||
/** Part number of the memory module */
|
||||
partNum?: Maybe<Scalars['String']['output']>;
|
||||
/** Serial number of the memory module */
|
||||
serialNum?: Maybe<Scalars['String']['output']>;
|
||||
/** Memory module size in bytes */
|
||||
size: Scalars['BigInt']['output'];
|
||||
/** Memory type (e.g., DDR4, DDR5) */
|
||||
type?: Maybe<Scalars['String']['output']>;
|
||||
/** Configured voltage in millivolts */
|
||||
voltageConfigured?: Maybe<Scalars['Int']['output']>;
|
||||
/** Maximum voltage in millivolts */
|
||||
voltageMax?: Maybe<Scalars['Int']['output']>;
|
||||
/** Minimum voltage in millivolts */
|
||||
voltageMin?: Maybe<Scalars['Int']['output']>;
|
||||
};
|
||||
|
||||
export type MemoryUtilization = Node & {
|
||||
__typename?: 'MemoryUtilization';
|
||||
/** Active memory in bytes */
|
||||
active: Scalars['BigInt']['output'];
|
||||
/** Available memory in bytes */
|
||||
available: Scalars['BigInt']['output'];
|
||||
/** Buffer/cache memory in bytes */
|
||||
buffcache: Scalars['BigInt']['output'];
|
||||
/** Free memory in bytes */
|
||||
free: Scalars['BigInt']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Swap usage percentage */
|
||||
percentSwapTotal: Scalars['Float']['output'];
|
||||
/** Memory usage percentage */
|
||||
percentTotal: Scalars['Float']['output'];
|
||||
/** Free swap memory in bytes */
|
||||
swapFree: Scalars['BigInt']['output'];
|
||||
/** Total swap memory in bytes */
|
||||
swapTotal: Scalars['BigInt']['output'];
|
||||
/** Used swap memory in bytes */
|
||||
swapUsed: Scalars['BigInt']['output'];
|
||||
/** Total system memory in bytes */
|
||||
total: Scalars['BigInt']['output'];
|
||||
/** Used memory in bytes */
|
||||
used: Scalars['BigInt']['output'];
|
||||
};
|
||||
|
||||
/** System metrics including CPU and memory utilization */
|
||||
export type Metrics = Node & {
|
||||
__typename?: 'Metrics';
|
||||
/** Current CPU utilization metrics */
|
||||
cpu?: Maybe<CpuUtilization>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Current memory utilization metrics */
|
||||
memory?: Maybe<MemoryUtilization>;
|
||||
};
|
||||
|
||||
/** The status of the minigraph */
|
||||
export enum MinigraphStatus {
|
||||
CONNECTED = 'CONNECTED',
|
||||
@@ -1237,23 +1488,6 @@ export type OrganizerResource = {
|
||||
type: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type Os = Node & {
|
||||
__typename?: 'Os';
|
||||
arch?: Maybe<Scalars['String']['output']>;
|
||||
build?: Maybe<Scalars['String']['output']>;
|
||||
codename?: Maybe<Scalars['String']['output']>;
|
||||
codepage?: Maybe<Scalars['String']['output']>;
|
||||
distro?: Maybe<Scalars['String']['output']>;
|
||||
hostname?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
kernel?: Maybe<Scalars['String']['output']>;
|
||||
logofile?: Maybe<Scalars['String']['output']>;
|
||||
platform?: Maybe<Scalars['String']['output']>;
|
||||
release?: Maybe<Scalars['String']['output']>;
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
uptime?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Owner = {
|
||||
__typename?: 'Owner';
|
||||
avatar: Scalars['String']['output'];
|
||||
@@ -1261,6 +1495,26 @@ export type Owner = {
|
||||
username: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type PackageVersions = {
|
||||
__typename?: 'PackageVersions';
|
||||
/** Docker version */
|
||||
docker?: Maybe<Scalars['String']['output']>;
|
||||
/** Git version */
|
||||
git?: Maybe<Scalars['String']['output']>;
|
||||
/** nginx version */
|
||||
nginx?: Maybe<Scalars['String']['output']>;
|
||||
/** Node.js version */
|
||||
node?: Maybe<Scalars['String']['output']>;
|
||||
/** npm version */
|
||||
npm?: Maybe<Scalars['String']['output']>;
|
||||
/** OpenSSL version */
|
||||
openssl?: Maybe<Scalars['String']['output']>;
|
||||
/** PHP version */
|
||||
php?: Maybe<Scalars['String']['output']>;
|
||||
/** pm2 version */
|
||||
pm2?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type ParityCheck = {
|
||||
__typename?: 'ParityCheck';
|
||||
/** Whether corrections are being written to parity */
|
||||
@@ -1280,7 +1534,7 @@ export type ParityCheck = {
|
||||
/** Speed of the parity check, in MB/s */
|
||||
speed?: Maybe<Scalars['String']['output']>;
|
||||
/** Status of the parity check */
|
||||
status?: Maybe<Scalars['String']['output']>;
|
||||
status: ParityCheckStatus;
|
||||
};
|
||||
|
||||
/** Parity check related mutations, WIP, response types and functionaliy will change */
|
||||
@@ -1302,22 +1556,19 @@ export type ParityCheckMutationsStartArgs = {
|
||||
correct: Scalars['Boolean']['input'];
|
||||
};
|
||||
|
||||
export type Pci = Node & {
|
||||
__typename?: 'Pci';
|
||||
blacklisted?: Maybe<Scalars['String']['output']>;
|
||||
class?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
productid?: Maybe<Scalars['String']['output']>;
|
||||
productname?: Maybe<Scalars['String']['output']>;
|
||||
type?: Maybe<Scalars['String']['output']>;
|
||||
typeid?: Maybe<Scalars['String']['output']>;
|
||||
vendorid?: Maybe<Scalars['String']['output']>;
|
||||
vendorname?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
export enum ParityCheckStatus {
|
||||
CANCELLED = 'CANCELLED',
|
||||
COMPLETED = 'COMPLETED',
|
||||
FAILED = 'FAILED',
|
||||
NEVER_RUN = 'NEVER_RUN',
|
||||
PAUSED = 'PAUSED',
|
||||
RUNNING = 'RUNNING'
|
||||
}
|
||||
|
||||
export type Permission = {
|
||||
__typename?: 'Permission';
|
||||
actions: Array<Scalars['String']['output']>;
|
||||
/** Actions allowed on this resource */
|
||||
actions: Array<AuthAction>;
|
||||
resource: Resource;
|
||||
};
|
||||
|
||||
@@ -1385,15 +1636,21 @@ export type Query = {
|
||||
customization?: Maybe<Customization>;
|
||||
disk: Disk;
|
||||
disks: Array<Disk>;
|
||||
display: Display;
|
||||
docker: Docker;
|
||||
flash: Flash;
|
||||
/** Get JSON Schema for API key creation form */
|
||||
getApiKeyCreationFormSchema: ApiKeyFormSettings;
|
||||
/** Get all available authentication actions with possession */
|
||||
getAvailableAuthActions: Array<AuthAction>;
|
||||
/** Get the actual permissions that would be granted by a set of roles */
|
||||
getPermissionsForRoles: Array<Permission>;
|
||||
info: Info;
|
||||
isInitialSetup: Scalars['Boolean']['output'];
|
||||
isSSOEnabled: Scalars['Boolean']['output'];
|
||||
logFile: LogFileContent;
|
||||
logFiles: Array<LogFile>;
|
||||
me: UserAccount;
|
||||
metrics: Metrics;
|
||||
network: Network;
|
||||
/** Get all notifications */
|
||||
notifications: Notifications;
|
||||
@@ -1406,6 +1663,8 @@ export type Query = {
|
||||
parityHistory: Array<ParityCheck>;
|
||||
/** List all installed plugins with their metadata */
|
||||
plugins: Array<Plugin>;
|
||||
/** Preview the effective permissions for a combination of roles and explicit permissions */
|
||||
previewEffectivePermissions: Array<Permission>;
|
||||
/** Get public OIDC provider information for login buttons */
|
||||
publicOidcProviders: Array<PublicOidcProvider>;
|
||||
publicPartnerInfo?: Maybe<PublicPartnerInfo>;
|
||||
@@ -1439,6 +1698,11 @@ export type QueryDiskArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryGetPermissionsForRolesArgs = {
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
|
||||
export type QueryLogFileArgs = {
|
||||
lines?: InputMaybe<Scalars['Int']['input']>;
|
||||
path: Scalars['String']['input'];
|
||||
@@ -1451,6 +1715,12 @@ export type QueryOidcProviderArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryPreviewEffectivePermissionsArgs = {
|
||||
permissions?: InputMaybe<Array<AddPermissionInput>>;
|
||||
roles?: InputMaybe<Array<Role>>;
|
||||
};
|
||||
|
||||
|
||||
export type QueryUpsDeviceByIdArgs = {
|
||||
id: Scalars['String']['input'];
|
||||
};
|
||||
@@ -1643,10 +1913,14 @@ export enum Resource {
|
||||
|
||||
/** Available roles for API keys and users */
|
||||
export enum Role {
|
||||
/** Full administrative access to all resources */
|
||||
ADMIN = 'ADMIN',
|
||||
/** Internal Role for Unraid Connect */
|
||||
CONNECT = 'CONNECT',
|
||||
/** Basic read access to user profile only */
|
||||
GUEST = 'GUEST',
|
||||
USER = 'USER'
|
||||
/** Read-only access to all resources */
|
||||
VIEWER = 'VIEWER'
|
||||
}
|
||||
|
||||
export type Server = Node & {
|
||||
@@ -1743,14 +2017,14 @@ export type SsoSettings = Node & {
|
||||
export type Subscription = {
|
||||
__typename?: 'Subscription';
|
||||
arraySubscription: UnraidArray;
|
||||
displaySubscription: Display;
|
||||
infoSubscription: Info;
|
||||
logFile: LogFileContent;
|
||||
notificationAdded: Notification;
|
||||
notificationsOverview: NotificationOverview;
|
||||
ownerSubscription: Owner;
|
||||
parityHistorySubscription: ParityCheck;
|
||||
serversSubscription: Server;
|
||||
systemMetricsCpu: CpuUtilization;
|
||||
systemMetricsMemory: MemoryUtilization;
|
||||
upsUpdates: UpsDevice;
|
||||
};
|
||||
|
||||
@@ -1759,21 +2033,10 @@ export type SubscriptionLogFileArgs = {
|
||||
path: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type System = Node & {
|
||||
__typename?: 'System';
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
manufacturer?: Maybe<Scalars['String']['output']>;
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
sku?: Maybe<Scalars['String']['output']>;
|
||||
uuid?: Maybe<Scalars['String']['output']>;
|
||||
version?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
/** Temperature unit (Celsius or Fahrenheit) */
|
||||
/** Temperature unit */
|
||||
export enum Temperature {
|
||||
C = 'C',
|
||||
F = 'F'
|
||||
CELSIUS = 'CELSIUS',
|
||||
FAHRENHEIT = 'FAHRENHEIT'
|
||||
}
|
||||
|
||||
export type Theme = {
|
||||
@@ -1934,7 +2197,7 @@ export enum UrlType {
|
||||
WIREGUARD = 'WIREGUARD'
|
||||
}
|
||||
|
||||
export type UnifiedSettings = Node & {
|
||||
export type UnifiedSettings = FormSchema & Node & {
|
||||
__typename?: 'UnifiedSettings';
|
||||
/** The data schema for the settings */
|
||||
dataSchema: Scalars['JSON']['output'];
|
||||
@@ -1958,6 +2221,8 @@ export type UnraidArray = Node & {
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Parity disks in the current array */
|
||||
parities: Array<ArrayDisk>;
|
||||
/** Current parity check status */
|
||||
parityCheckStatus: ParityCheck;
|
||||
/** Current array state */
|
||||
state: ArrayState;
|
||||
};
|
||||
@@ -1985,12 +2250,6 @@ export type Uptime = {
|
||||
timestamp?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Usb = Node & {
|
||||
__typename?: 'Usb';
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type UserAccount = Node & {
|
||||
__typename?: 'UserAccount';
|
||||
/** A description of the user */
|
||||
@@ -2168,37 +2427,6 @@ export type Vars = Node & {
|
||||
workgroup?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Versions = Node & {
|
||||
__typename?: 'Versions';
|
||||
apache?: Maybe<Scalars['String']['output']>;
|
||||
docker?: Maybe<Scalars['String']['output']>;
|
||||
gcc?: Maybe<Scalars['String']['output']>;
|
||||
git?: Maybe<Scalars['String']['output']>;
|
||||
grunt?: Maybe<Scalars['String']['output']>;
|
||||
gulp?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
kernel?: Maybe<Scalars['String']['output']>;
|
||||
mongodb?: Maybe<Scalars['String']['output']>;
|
||||
mysql?: Maybe<Scalars['String']['output']>;
|
||||
nginx?: Maybe<Scalars['String']['output']>;
|
||||
node?: Maybe<Scalars['String']['output']>;
|
||||
npm?: Maybe<Scalars['String']['output']>;
|
||||
openssl?: Maybe<Scalars['String']['output']>;
|
||||
perl?: Maybe<Scalars['String']['output']>;
|
||||
php?: Maybe<Scalars['String']['output']>;
|
||||
pm2?: Maybe<Scalars['String']['output']>;
|
||||
postfix?: Maybe<Scalars['String']['output']>;
|
||||
postgresql?: Maybe<Scalars['String']['output']>;
|
||||
python?: Maybe<Scalars['String']['output']>;
|
||||
redis?: Maybe<Scalars['String']['output']>;
|
||||
systemOpenssl?: Maybe<Scalars['String']['output']>;
|
||||
systemOpensslLib?: Maybe<Scalars['String']['output']>;
|
||||
tsc?: Maybe<Scalars['String']['output']>;
|
||||
unraid?: Maybe<Scalars['String']['output']>;
|
||||
v8?: Maybe<Scalars['String']['output']>;
|
||||
yarn?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type VmDomain = Node & {
|
||||
__typename?: 'VmDomain';
|
||||
/** The unique identifier for the vm (uuid) */
|
||||
@@ -2349,7 +2577,7 @@ export type GetSsoUsersQuery = { __typename?: 'Query', settings: { __typename?:
|
||||
export type SystemReportQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type SystemReportQuery = { __typename?: 'Query', info: { __typename?: 'Info', id: any, machineId?: any | null, system: { __typename?: 'System', manufacturer?: string | null, model?: string | null, version?: string | null, sku?: string | null, serial?: string | null, uuid?: string | null }, versions: { __typename?: 'Versions', unraid?: string | null, kernel?: string | null, openssl?: string | null } }, config: { __typename?: 'Config', id: any, valid?: boolean | null, error?: string | null }, server?: { __typename?: 'Server', id: any, name: string } | null };
|
||||
export type SystemReportQuery = { __typename?: 'Query', info: { __typename?: 'Info', id: any, machineId?: string | null, system: { __typename?: 'InfoSystem', manufacturer?: string | null, model?: string | null, version?: string | null, sku?: string | null, serial?: string | null, uuid?: string | null }, versions: { __typename?: 'InfoVersions', core: { __typename?: 'CoreVersions', unraid?: string | null, kernel?: string | null }, packages?: { __typename?: 'PackageVersions', openssl?: string | null } | null } }, config: { __typename?: 'Config', id: any, valid?: boolean | null, error?: string | null }, server?: { __typename?: 'Server', id: any, name: string } | null };
|
||||
|
||||
export type ConnectStatusQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
@@ -2375,7 +2603,7 @@ export const UpdateSsoUsersDocument = {"kind":"Document","definitions":[{"kind":
|
||||
export const UpdateSandboxSettingsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateSandboxSettings"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateSettings"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"restartRequired"}},{"kind":"Field","name":{"kind":"Name","value":"values"}}]}}]}}]} as unknown as DocumentNode<UpdateSandboxSettingsMutation, UpdateSandboxSettingsMutationVariables>;
|
||||
export const GetPluginsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPlugins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"plugins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"hasApiModule"}},{"kind":"Field","name":{"kind":"Name","value":"hasCliModule"}}]}}]}}]} as unknown as DocumentNode<GetPluginsQuery, GetPluginsQueryVariables>;
|
||||
export const GetSsoUsersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSSOUsers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"settings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"api"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ssoSubIds"}}]}}]}}]}}]} as unknown as DocumentNode<GetSsoUsersQuery, GetSsoUsersQueryVariables>;
|
||||
export const SystemReportDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SystemReport"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"machineId"}},{"kind":"Field","name":{"kind":"Name","value":"system"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"manufacturer"}},{"kind":"Field","name":{"kind":"Name","value":"model"}},{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"sku"}},{"kind":"Field","name":{"kind":"Name","value":"serial"}},{"kind":"Field","name":{"kind":"Name","value":"uuid"}}]}},{"kind":"Field","name":{"kind":"Name","value":"versions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unraid"}},{"kind":"Field","name":{"kind":"Name","value":"kernel"}},{"kind":"Field","name":{"kind":"Name","value":"openssl"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"server"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<SystemReportQuery, SystemReportQueryVariables>;
|
||||
export const SystemReportDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SystemReport"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"machineId"}},{"kind":"Field","name":{"kind":"Name","value":"system"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"manufacturer"}},{"kind":"Field","name":{"kind":"Name","value":"model"}},{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"sku"}},{"kind":"Field","name":{"kind":"Name","value":"serial"}},{"kind":"Field","name":{"kind":"Name","value":"uuid"}}]}},{"kind":"Field","name":{"kind":"Name","value":"versions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"core"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unraid"}},{"kind":"Field","name":{"kind":"Name","value":"kernel"}}]}},{"kind":"Field","name":{"kind":"Name","value":"packages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"openssl"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"server"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<SystemReportQuery, SystemReportQueryVariables>;
|
||||
export const ConnectStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ConnectStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connect"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dynamicRemoteAccess"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enabledType"}},{"kind":"Field","name":{"kind":"Name","value":"runningType"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]}}]} as unknown as DocumentNode<ConnectStatusQuery, ConnectStatusQueryVariables>;
|
||||
export const ServicesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"online"}},{"kind":"Field","name":{"kind":"Name","value":"uptime"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"timestamp"}}]}},{"kind":"Field","name":{"kind":"Name","value":"version"}}]}}]}}]} as unknown as DocumentNode<ServicesQuery, ServicesQueryVariables>;
|
||||
export const ValidateOidcSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidateOidcSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"validateOidcSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]}}]} as unknown as DocumentNode<ValidateOidcSessionQuery, ValidateOidcSessionQueryVariables>;
|
||||
@@ -1,203 +0,0 @@
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import type { InternalGraphQLClientFactory } from '@unraid/shared';
|
||||
import { ApolloClient } from '@apollo/client/core/index.js';
|
||||
import { INTERNAL_CLIENT_SERVICE_TOKEN } from '@unraid/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
|
||||
describe('CliInternalClientService', () => {
|
||||
let service: CliInternalClientService;
|
||||
let clientFactory: InternalGraphQLClientFactory;
|
||||
let adminKeyService: AdminKeyService;
|
||||
let module: TestingModule;
|
||||
|
||||
const mockApolloClient = {
|
||||
query: vi.fn(),
|
||||
mutate: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
module = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot()],
|
||||
providers: [
|
||||
CliInternalClientService,
|
||||
{
|
||||
provide: INTERNAL_CLIENT_SERVICE_TOKEN,
|
||||
useValue: {
|
||||
createClient: vi.fn().mockResolvedValue(mockApolloClient),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: AdminKeyService,
|
||||
useValue: {
|
||||
getOrCreateLocalAdminKey: vi.fn().mockResolvedValue('test-admin-key'),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<CliInternalClientService>(CliInternalClientService);
|
||||
clientFactory = module.get<InternalGraphQLClientFactory>(INTERNAL_CLIENT_SERVICE_TOKEN);
|
||||
adminKeyService = module.get<AdminKeyService>(AdminKeyService);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await module?.close();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('dependency injection', () => {
|
||||
it('should have InternalGraphQLClientFactory injected', () => {
|
||||
expect(clientFactory).toBeDefined();
|
||||
expect(clientFactory.createClient).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have AdminKeyService injected', () => {
|
||||
expect(adminKeyService).toBeDefined();
|
||||
expect(adminKeyService.getOrCreateLocalAdminKey).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClient', () => {
|
||||
it('should create a client with getApiKey function', async () => {
|
||||
const client = await service.getClient();
|
||||
|
||||
// The API key is now fetched lazily, not immediately
|
||||
expect(clientFactory.createClient).toHaveBeenCalledWith({
|
||||
getApiKey: expect.any(Function),
|
||||
enableSubscriptions: false,
|
||||
});
|
||||
|
||||
// Verify the getApiKey function works correctly when called
|
||||
const callArgs = vi.mocked(clientFactory.createClient).mock.calls[0][0];
|
||||
const apiKey = await callArgs.getApiKey();
|
||||
expect(apiKey).toBe('test-admin-key');
|
||||
expect(adminKeyService.getOrCreateLocalAdminKey).toHaveBeenCalled();
|
||||
|
||||
expect(client).toBe(mockApolloClient);
|
||||
});
|
||||
|
||||
it('should return cached client on subsequent calls', async () => {
|
||||
const client1 = await service.getClient();
|
||||
const client2 = await service.getClient();
|
||||
|
||||
expect(client1).toBe(client2);
|
||||
expect(clientFactory.createClient).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle errors when getting admin key', async () => {
|
||||
const error = new Error('Failed to get admin key');
|
||||
vi.mocked(adminKeyService.getOrCreateLocalAdminKey).mockRejectedValueOnce(error);
|
||||
|
||||
// The client creation will succeed, but the API key error happens later
|
||||
const client = await service.getClient();
|
||||
expect(client).toBe(mockApolloClient);
|
||||
|
||||
// Now test that the getApiKey function throws the expected error
|
||||
const callArgs = vi.mocked(clientFactory.createClient).mock.calls[0][0];
|
||||
await expect(callArgs.getApiKey()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearClient', () => {
|
||||
it('should stop and clear the client', async () => {
|
||||
// First create a client
|
||||
await service.getClient();
|
||||
|
||||
// Clear the client
|
||||
service.clearClient();
|
||||
|
||||
expect(mockApolloClient.stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle clearing when no client exists', () => {
|
||||
// Should not throw when clearing a non-existent client
|
||||
expect(() => service.clearClient()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should create a new client after clearing', async () => {
|
||||
// Create initial client
|
||||
await service.getClient();
|
||||
|
||||
// Clear it
|
||||
service.clearClient();
|
||||
|
||||
// Create new client
|
||||
await service.getClient();
|
||||
|
||||
// Should have created client twice
|
||||
expect(clientFactory.createClient).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('race condition protection', () => {
|
||||
it('should prevent stale client resurrection when clearClient() is called during creation', async () => {
|
||||
let resolveClientCreation!: (client: any) => void;
|
||||
|
||||
// Mock createClient to return a controllable promise
|
||||
const clientCreationPromise = new Promise<any>((resolve) => {
|
||||
resolveClientCreation = resolve;
|
||||
});
|
||||
vi.mocked(clientFactory.createClient).mockReturnValueOnce(clientCreationPromise);
|
||||
|
||||
// Start client creation (but don't await yet)
|
||||
const getClientPromise = service.getClient();
|
||||
|
||||
// Clear the client while creation is in progress
|
||||
service.clearClient();
|
||||
|
||||
// Now complete the client creation
|
||||
resolveClientCreation(mockApolloClient);
|
||||
|
||||
// Wait for getClient to complete
|
||||
const client = await getClientPromise;
|
||||
|
||||
// The client should be returned from getClient
|
||||
expect(client).toBe(mockApolloClient);
|
||||
|
||||
// But subsequent getClient calls should create a new client
|
||||
// because the race condition protection prevented assignment
|
||||
await service.getClient();
|
||||
|
||||
// Should have created a second client, proving the first wasn't assigned
|
||||
expect(clientFactory.createClient).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle concurrent getClient calls during race condition', async () => {
|
||||
let resolveClientCreation!: (client: any) => void;
|
||||
|
||||
// Mock createClient to return a controllable promise
|
||||
const clientCreationPromise = new Promise<any>((resolve) => {
|
||||
resolveClientCreation = resolve;
|
||||
});
|
||||
vi.mocked(clientFactory.createClient).mockReturnValueOnce(clientCreationPromise);
|
||||
|
||||
// Start multiple concurrent client creation calls
|
||||
const getClientPromise1 = service.getClient();
|
||||
const getClientPromise2 = service.getClient(); // Should wait for first one
|
||||
|
||||
// Clear the client while creation is in progress
|
||||
service.clearClient();
|
||||
|
||||
// Complete the client creation
|
||||
resolveClientCreation(mockApolloClient);
|
||||
|
||||
// Both calls should resolve with the same client
|
||||
const [client1, client2] = await Promise.all([getClientPromise1, getClientPromise2]);
|
||||
expect(client1).toBe(mockApolloClient);
|
||||
expect(client2).toBe(mockApolloClient);
|
||||
|
||||
// But the client should not be cached due to race condition protection
|
||||
await service.getClient();
|
||||
expect(clientFactory.createClient).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import type { InternalGraphQLClientFactory } from '@unraid/shared';
|
||||
import { ApolloClient, NormalizedCacheObject } from '@apollo/client/core/index.js';
|
||||
import { INTERNAL_CLIENT_SERVICE_TOKEN } from '@unraid/shared';
|
||||
|
||||
import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
|
||||
|
||||
/**
|
||||
* Internal GraphQL client for CLI commands.
|
||||
*
|
||||
* This service creates an Apollo client that queries the local API server
|
||||
* with admin privileges for CLI operations.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CliInternalClientService {
|
||||
private readonly logger = new Logger(CliInternalClientService.name);
|
||||
private client: ApolloClient<NormalizedCacheObject> | null = null;
|
||||
private creatingClient: Promise<ApolloClient<NormalizedCacheObject>> | null = null;
|
||||
|
||||
constructor(
|
||||
@Inject(INTERNAL_CLIENT_SERVICE_TOKEN)
|
||||
private readonly clientFactory: InternalGraphQLClientFactory,
|
||||
private readonly adminKeyService: AdminKeyService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the admin API key using the AdminKeyService.
|
||||
* This ensures the key exists and is available for CLI operations.
|
||||
*/
|
||||
private async getLocalApiKey(): Promise<string> {
|
||||
try {
|
||||
return await this.adminKeyService.getOrCreateLocalAdminKey();
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get admin API key:', error);
|
||||
throw new Error(
|
||||
'Unable to get admin API key for internal client. Ensure the API server is running.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default CLI client with admin API key.
|
||||
* This is for CLI commands that need admin access.
|
||||
*/
|
||||
public async getClient(): Promise<ApolloClient<NormalizedCacheObject>> {
|
||||
// If client already exists, return it
|
||||
if (this.client) {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
// If another call is already creating the client, wait for it
|
||||
if (this.creatingClient) {
|
||||
return await this.creatingClient;
|
||||
}
|
||||
|
||||
// Start creating the client with race condition protection
|
||||
let creationPromise!: Promise<ApolloClient<NormalizedCacheObject>>;
|
||||
// eslint-disable-next-line prefer-const
|
||||
creationPromise = (async () => {
|
||||
try {
|
||||
const client = await this.clientFactory.createClient({
|
||||
getApiKey: () => this.getLocalApiKey(),
|
||||
enableSubscriptions: false, // CLI doesn't need subscriptions
|
||||
});
|
||||
|
||||
// awaiting *before* checking this.creatingClient is important!
|
||||
// by yielding to the event loop, it ensures
|
||||
// `this.creatingClient = creationPromise;` is executed before the next check.
|
||||
|
||||
// This prevents race conditions where the client is assigned to the wrong instance.
|
||||
// Only assign client if this creation is still current
|
||||
if (this.creatingClient === creationPromise) {
|
||||
this.client = client;
|
||||
this.logger.debug('Created CLI internal GraphQL client with admin privileges');
|
||||
}
|
||||
|
||||
return client;
|
||||
} finally {
|
||||
// Only clear if this creation is still current
|
||||
if (this.creatingClient === creationPromise) {
|
||||
this.creatingClient = null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
this.creatingClient = creationPromise;
|
||||
return await creationPromise;
|
||||
}
|
||||
|
||||
public clearClient() {
|
||||
// Stop the Apollo client to terminate any active processes
|
||||
this.client?.stop();
|
||||
this.client = null;
|
||||
this.creatingClient = null;
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,13 @@ export const SYSTEM_REPORT_QUERY = gql(`
|
||||
uuid
|
||||
}
|
||||
versions {
|
||||
unraid
|
||||
kernel
|
||||
openssl
|
||||
core {
|
||||
unraid
|
||||
kernel
|
||||
}
|
||||
packages {
|
||||
openssl
|
||||
}
|
||||
}
|
||||
}
|
||||
config {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
|
||||
import type { CanonicalInternalClientService } from '@unraid/shared';
|
||||
import { CANONICAL_INTERNAL_CLIENT_TOKEN } from '@unraid/shared';
|
||||
import { CommandRunner, SubCommand } from 'nest-commander';
|
||||
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { VALIDATE_OIDC_SESSION_QUERY } from '@app/unraid-api/cli/queries/validate-oidc-session.query.js';
|
||||
|
||||
@@ -13,7 +16,8 @@ import { VALIDATE_OIDC_SESSION_QUERY } from '@app/unraid-api/cli/queries/validat
|
||||
export class ValidateTokenCommand extends CommandRunner {
|
||||
constructor(
|
||||
private readonly logger: LogService,
|
||||
private readonly internalClient: CliInternalClientService
|
||||
@Inject(CANONICAL_INTERNAL_CLIENT_TOKEN)
|
||||
private readonly internalClient: CanonicalInternalClientService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -45,7 +49,7 @@ export class ValidateTokenCommand extends CommandRunner {
|
||||
|
||||
private async validateOidcToken(token: string): Promise<void> {
|
||||
try {
|
||||
const client = await this.internalClient.getClient();
|
||||
const client = await this.internalClient.getClient({ enableSubscriptions: false });
|
||||
const { data, errors } = await client.query({
|
||||
query: VALIDATE_OIDC_SESSION_QUERY,
|
||||
variables: { token },
|
||||
|
||||
@@ -5,7 +5,7 @@ import { LogRotateService } from '@app/unraid-api/cron/log-rotate.service.js';
|
||||
import { WriteFlashFileService } from '@app/unraid-api/cron/write-flash-file.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot()],
|
||||
imports: [],
|
||||
providers: [WriteFlashFileService, LogRotateService],
|
||||
})
|
||||
export class CronModule {}
|
||||
|
||||
3
api/src/unraid-api/graph/auth/auth-action.enum.ts
Normal file
3
api/src/unraid-api/graph/auth/auth-action.enum.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// All enum registrations have been moved to @unraid/shared/graphql.model.js
|
||||
// Just re-export AuthAction for convenience
|
||||
export { AuthAction } from '@unraid/shared/graphql.model.js';
|
||||
@@ -1,52 +1,3 @@
|
||||
import { DirectiveLocation, GraphQLDirective, GraphQLEnumType, GraphQLString } from 'graphql';
|
||||
import { AuthActionVerb, AuthPossession } from 'nest-authz';
|
||||
|
||||
// Create GraphQL enum types for auth action verbs and possessions
|
||||
export const AuthActionVerbEnum = new GraphQLEnumType({
|
||||
name: 'AuthActionVerb',
|
||||
description: 'Available authentication action verbs',
|
||||
values: Object.entries(AuthActionVerb)
|
||||
.filter(([key]) => isNaN(Number(key))) // Filter out numeric keys
|
||||
.reduce(
|
||||
(acc, [key]) => {
|
||||
acc[key] = { value: key };
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { value: string }>
|
||||
),
|
||||
});
|
||||
|
||||
export const AuthPossessionEnum = new GraphQLEnumType({
|
||||
name: 'AuthPossession',
|
||||
description: 'Available authentication possession types',
|
||||
values: Object.entries(AuthPossession)
|
||||
.filter(([key]) => isNaN(Number(key))) // Filter out numeric keys
|
||||
.reduce(
|
||||
(acc, [key]) => {
|
||||
acc[key] = { value: key };
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { value: string }>
|
||||
),
|
||||
});
|
||||
|
||||
// Create the auth directive
|
||||
export const AuthDirective = new GraphQLDirective({
|
||||
name: 'auth',
|
||||
description: 'Directive to control access to fields based on authentication',
|
||||
locations: [DirectiveLocation.FIELD_DEFINITION],
|
||||
args: {
|
||||
action: {
|
||||
type: AuthActionVerbEnum,
|
||||
description: 'The action verb required for access',
|
||||
},
|
||||
resource: {
|
||||
type: GraphQLString,
|
||||
description: 'The resource required for access',
|
||||
},
|
||||
possession: {
|
||||
type: AuthPossessionEnum,
|
||||
description: 'The possession type required for access',
|
||||
},
|
||||
},
|
||||
});
|
||||
// Resource and Role enums are already registered in @unraid/shared/graphql.model.js
|
||||
// Just re-export them here for convenience
|
||||
export { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
|
||||
@@ -12,6 +12,10 @@ import { NoUnusedVariablesRule } from 'graphql';
|
||||
|
||||
import { ENVIRONMENT } from '@app/environment.js';
|
||||
import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js';
|
||||
|
||||
// Import enum registrations to ensure they're registered with GraphQL
|
||||
import '@app/unraid-api/graph/auth/auth-action.enum.js';
|
||||
|
||||
import { createDynamicIntrospectionPlugin } from '@app/unraid-api/graph/introspection-plugin.js';
|
||||
import { ResolversModule } from '@app/unraid-api/graph/resolvers/resolvers.module.js';
|
||||
import { createSandboxPlugin } from '@app/unraid-api/graph/sandbox-plugin.js';
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { ApiKeyFormService } from '@app/unraid-api/graph/resolvers/api-key/api-key-form.service.js';
|
||||
import { ApiKeyFormSettings } from '@app/unraid-api/graph/resolvers/settings/settings.model.js';
|
||||
|
||||
@Injectable()
|
||||
@Resolver()
|
||||
export class ApiKeyFormResolver {
|
||||
constructor(private apiKeyFormService: ApiKeyFormService) {}
|
||||
|
||||
@Query(() => ApiKeyFormSettings, {
|
||||
description: 'Get JSON Schema for API key creation form',
|
||||
})
|
||||
@UsePermissions({
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
})
|
||||
getApiKeyCreationFormSchema(): ApiKeyFormSettings {
|
||||
return this.apiKeyFormService.getApiKeyCreationFormSchema();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ApiKeyFormData,
|
||||
ApiKeyFormService,
|
||||
} from '@app/unraid-api/graph/resolvers/api-key/api-key-form.service.js';
|
||||
|
||||
describe('ApiKeyFormService', () => {
|
||||
let service: ApiKeyFormService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new ApiKeyFormService();
|
||||
});
|
||||
|
||||
describe('convertFormDataToPermissions', () => {
|
||||
describe('basic functionality', () => {
|
||||
it('should merge roles and custom permissions', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
roles: [Role.ADMIN],
|
||||
customPermissions: [
|
||||
{
|
||||
resources: [Resource.NETWORK],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.roles).toEqual([Role.ADMIN]);
|
||||
expect(result.permissions).toContainEqual({
|
||||
resource: Resource.NETWORK,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle only roles when others are not provided', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
roles: [Role.GUEST, Role.VIEWER],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.roles).toEqual([Role.GUEST, Role.VIEWER]);
|
||||
expect(result.permissions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle multiple roles', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
roles: [Role.GUEST, Role.VIEWER, Role.ADMIN],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.roles).toEqual([Role.GUEST, Role.VIEWER, Role.ADMIN]);
|
||||
expect(result.permissions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle only custom permissions when others are not provided', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
customPermissions: [
|
||||
{
|
||||
resources: [Resource.ARRAY, Resource.DISK],
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.roles).toEqual([]);
|
||||
expect(result.permissions).toContainEqual({
|
||||
resource: Resource.ARRAY,
|
||||
actions: expect.arrayContaining([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]),
|
||||
});
|
||||
expect(result.permissions).toContainEqual({
|
||||
resource: Resource.DISK,
|
||||
actions: expect.arrayContaining([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty form data', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.roles).toEqual([]);
|
||||
expect(result.permissions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom permissions handling', () => {
|
||||
it('should merge custom permissions with same resource', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
customPermissions: [
|
||||
{
|
||||
resources: [Resource.DOCKER],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
{
|
||||
resources: [Resource.DOCKER],
|
||||
actions: [AuthAction.UPDATE_ANY, AuthAction.DELETE_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.permissions).toEqual([
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: expect.arrayContaining([
|
||||
AuthAction.READ_ANY,
|
||||
AuthAction.UPDATE_ANY,
|
||||
AuthAction.DELETE_ANY,
|
||||
]),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should deduplicate actions when merging', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
customPermissions: [
|
||||
{
|
||||
resources: [Resource.NETWORK],
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
{
|
||||
resources: [Resource.NETWORK],
|
||||
actions: [AuthAction.READ_ANY, AuthAction.DELETE_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
const networkPermission = result.permissions.find(
|
||||
(p) => p.resource === Resource.NETWORK
|
||||
);
|
||||
expect(networkPermission?.actions).toHaveLength(3);
|
||||
expect(networkPermission?.actions).toContain(AuthAction.READ_ANY);
|
||||
expect(networkPermission?.actions).toContain(AuthAction.UPDATE_ANY);
|
||||
expect(networkPermission?.actions).toContain(AuthAction.DELETE_ANY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle resources as non-array in custom permissions', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
customPermissions: [
|
||||
{
|
||||
resources: Resource.DOCKER as any,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.permissions).toEqual([
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle actions as non-array in custom permissions', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
customPermissions: [
|
||||
{
|
||||
resources: [Resource.DOCKER],
|
||||
actions: AuthAction.READ_ANY as any,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.permissions).toEqual([
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty arrays gracefully', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
roles: [],
|
||||
customPermissions: [],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.roles).toEqual([]);
|
||||
expect(result.permissions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle both roles and custom permissions together', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
roles: [Role.VIEWER],
|
||||
customPermissions: [
|
||||
{
|
||||
resources: [Resource.DOCKER, Resource.VMS],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
{
|
||||
resources: [Resource.NETWORK],
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.roles).toEqual([Role.VIEWER]);
|
||||
expect(result.permissions).toHaveLength(3);
|
||||
expect(result.permissions).toContainEqual({
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
});
|
||||
expect(result.permissions).toContainEqual({
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
});
|
||||
expect(result.permissions).toContainEqual({
|
||||
resource: Resource.NETWORK,
|
||||
actions: expect.arrayContaining([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { JsonSchema, LabelElement, UISchemaElement } from '@jsonforms/core';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { mergeSettingSlices } from '@unraid/shared/jsonforms/settings.js';
|
||||
import { normalizeAction } from '@unraid/shared/util/permissions.js';
|
||||
import { capitalCase } from 'change-case';
|
||||
|
||||
import type { SettingSlice } from '@app/unraid-api/types/json-forms.js';
|
||||
import {
|
||||
createLabeledControl,
|
||||
createSimpleLabeledControl,
|
||||
} from '@app/unraid-api/graph/utils/form-utils.js';
|
||||
|
||||
// Helper to get GraphQL enum names for JSON Schema
|
||||
// GraphQL expects the enum names (keys) not the values
|
||||
function getAuthActionEnumNames(): string[] {
|
||||
// Get only the "_ANY" actions (not "_OWN")
|
||||
// e.g., CREATE_ANY, READ_ANY, UPDATE_ANY, DELETE_ANY
|
||||
return Object.keys(AuthAction).filter((key) => key === key.toUpperCase() && key.endsWith('_ANY'));
|
||||
}
|
||||
|
||||
// Helper to create labels for AuthAction enum dynamically
|
||||
function getAuthActionLabels(): Record<string, string> {
|
||||
const labels: Record<string, string> = {};
|
||||
|
||||
for (const enumName of getAuthActionEnumNames()) {
|
||||
// Convert CREATE_ANY -> Create (All)
|
||||
// Convert READ_OWN -> Read (Own)
|
||||
const [verb, possession] = enumName.split('_');
|
||||
const verbLabel = capitalCase(verb.toLowerCase());
|
||||
const possessionLabel = possession === 'ANY' ? 'All' : 'Own';
|
||||
labels[enumName] = `${verbLabel} (${possessionLabel})`;
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
export interface ApiKeyFormData {
|
||||
name: string;
|
||||
description?: string;
|
||||
roles?: Role[];
|
||||
permissionPresets?: string; // Single preset selection from dropdown
|
||||
customPermissions?: Array<{
|
||||
resources: Resource[]; // Form uses array for multi-select
|
||||
actions: string[];
|
||||
}>;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyFormService {
|
||||
/**
|
||||
* Generate form schema for API key creation
|
||||
*/
|
||||
getApiKeyCreationFormSchema(): {
|
||||
id: string;
|
||||
dataSchema: Record<string, any>;
|
||||
uiSchema: Record<string, any>;
|
||||
values: Record<string, any>;
|
||||
} {
|
||||
const slice = this.createApiKeyCreationSlice();
|
||||
const merged = mergeSettingSlices([slice]);
|
||||
|
||||
return {
|
||||
id: 'api-key-creation-form',
|
||||
dataSchema: {
|
||||
type: 'object',
|
||||
required: ['name'],
|
||||
properties: merged.properties,
|
||||
},
|
||||
uiSchema: {
|
||||
type: 'VerticalLayout',
|
||||
elements: merged.elements,
|
||||
},
|
||||
values: {},
|
||||
};
|
||||
}
|
||||
|
||||
private createApiKeyCreationSlice(): SettingSlice {
|
||||
const slice: SettingSlice = {
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
title: 'API Key Name',
|
||||
description: 'A descriptive name for this API key',
|
||||
minLength: 1,
|
||||
maxLength: 100,
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
title: 'Description',
|
||||
description: 'Optional description of what this key is used for',
|
||||
maxLength: 500,
|
||||
},
|
||||
roles: {
|
||||
type: 'array',
|
||||
title: 'Roles',
|
||||
description: 'Select one or more roles to grant pre-defined permission sets',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: this.getAvailableRoles(),
|
||||
},
|
||||
uniqueItems: true,
|
||||
},
|
||||
permissionPresets: {
|
||||
type: 'string',
|
||||
title: 'Add Permission Preset',
|
||||
description: 'Quick add common permission sets',
|
||||
enum: [
|
||||
'none',
|
||||
'docker_manager',
|
||||
'vm_manager',
|
||||
'monitoring',
|
||||
'backup_manager',
|
||||
'network_admin',
|
||||
],
|
||||
default: 'none',
|
||||
},
|
||||
customPermissions: {
|
||||
type: 'array',
|
||||
title: 'Permissions',
|
||||
description: 'Configure specific permissions',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
resources: {
|
||||
type: 'array',
|
||||
title: 'Resources',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: this.getAvailableResources(),
|
||||
},
|
||||
uniqueItems: true,
|
||||
minItems: 1,
|
||||
default: [this.getAvailableResources()[0]], // Set a default value as array
|
||||
},
|
||||
actions: {
|
||||
type: 'array',
|
||||
title: 'Actions',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: getAuthActionEnumNames(),
|
||||
},
|
||||
uniqueItems: true,
|
||||
minItems: 1,
|
||||
default: ['READ_ANY'], // Set a default action
|
||||
},
|
||||
},
|
||||
required: ['resources', 'actions'],
|
||||
},
|
||||
},
|
||||
// Commenting out expiration date until date picker is implemented
|
||||
// expiresAt: {
|
||||
// type: 'string',
|
||||
// format: 'date-time',
|
||||
// title: 'Expiration Date',
|
||||
// description: 'Optional expiration date for this API key',
|
||||
// },
|
||||
},
|
||||
elements: [
|
||||
createLabeledControl({
|
||||
scope: '#/properties/name',
|
||||
label: 'API Key Name',
|
||||
description: 'A descriptive name for this API key',
|
||||
layoutType: 'VerticalLayout',
|
||||
controlOptions: {
|
||||
inputType: 'text',
|
||||
},
|
||||
}),
|
||||
createLabeledControl({
|
||||
scope: '#/properties/description',
|
||||
label: 'Description',
|
||||
description: 'Optional description of what this key is used for',
|
||||
layoutType: 'VerticalLayout',
|
||||
controlOptions: {
|
||||
multi: true,
|
||||
rows: 3,
|
||||
},
|
||||
}),
|
||||
// Permissions section header
|
||||
{
|
||||
type: 'Label',
|
||||
text: 'Permissions Configuration',
|
||||
options: {
|
||||
format: 'title',
|
||||
},
|
||||
} as LabelElement,
|
||||
{
|
||||
type: 'Label',
|
||||
text: 'Select any combination of roles, permission groups, and custom permissions to define what this API key can access.',
|
||||
options: {
|
||||
format: 'description',
|
||||
},
|
||||
} as LabelElement,
|
||||
// Roles selection
|
||||
createLabeledControl({
|
||||
scope: '#/properties/roles',
|
||||
label: 'Roles',
|
||||
description: 'Select one or more roles to grant pre-defined permission sets',
|
||||
layoutType: 'VerticalLayout',
|
||||
controlOptions: {
|
||||
multiple: true,
|
||||
labels: this.getAvailableRoles().reduce(
|
||||
(acc, role) => ({
|
||||
...acc,
|
||||
[role]: capitalCase(role),
|
||||
}),
|
||||
{}
|
||||
),
|
||||
descriptions: this.getRoleDescriptions(),
|
||||
},
|
||||
}),
|
||||
// Separator for permissions
|
||||
{
|
||||
type: 'Label',
|
||||
text: 'Permissions',
|
||||
options: {
|
||||
format: 'subtitle',
|
||||
},
|
||||
} as LabelElement,
|
||||
{
|
||||
type: 'Label',
|
||||
text: 'Use the preset dropdown for common permission sets, or manually add custom permissions. You can select multiple resources that share the same actions.',
|
||||
options: {
|
||||
format: 'description',
|
||||
},
|
||||
} as LabelElement,
|
||||
// Permission preset dropdown
|
||||
createLabeledControl({
|
||||
scope: '#/properties/permissionPresets',
|
||||
label: 'Quick Add Presets',
|
||||
description: 'Select a preset to quickly add common permission sets',
|
||||
layoutType: 'VerticalLayout',
|
||||
controlOptions: {
|
||||
labels: {
|
||||
none: '-- Select a preset --',
|
||||
docker_manager: 'Docker Manager (Full Docker Control)',
|
||||
vm_manager: 'VM Manager (Full VM Control)',
|
||||
monitoring: 'Monitoring (Read-only System Info)',
|
||||
backup_manager: 'Backup Manager (Flash & Share Control)',
|
||||
network_admin: 'Network Admin (Network & Services Control)',
|
||||
},
|
||||
},
|
||||
}),
|
||||
// Custom permissions array - following OIDC pattern exactly
|
||||
{
|
||||
type: 'Control',
|
||||
scope: '#/properties/customPermissions',
|
||||
options: {
|
||||
elementLabelFormat: 'Permission Entry',
|
||||
itemTypeName: 'Permission',
|
||||
detail: {
|
||||
type: 'VerticalLayout',
|
||||
elements: [
|
||||
createSimpleLabeledControl({
|
||||
scope: '#/properties/resources',
|
||||
label: 'Resources:',
|
||||
description: 'Select the resources to grant permissions for',
|
||||
controlOptions: {
|
||||
multiple: true,
|
||||
labels: this.getAvailableResources().reduce(
|
||||
(acc, resource) => ({
|
||||
...acc,
|
||||
[resource]: capitalCase(
|
||||
resource.toLowerCase().replace(/_/g, ' ')
|
||||
),
|
||||
}),
|
||||
{}
|
||||
),
|
||||
},
|
||||
}),
|
||||
createSimpleLabeledControl({
|
||||
scope: '#/properties/actions',
|
||||
label: 'Actions:',
|
||||
description: 'Select the actions allowed on this resource',
|
||||
controlOptions: {
|
||||
multiple: true,
|
||||
labels: getAuthActionLabels(),
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
} as UISchemaElement,
|
||||
// Note: Datetime inputs are not currently supported in the renderer
|
||||
// Would need to implement a date picker component
|
||||
// For now, commenting out the expiration date field
|
||||
// createLabeledControl({
|
||||
// scope: '#/properties/expiresAt',
|
||||
// label: 'Expiration Date:',
|
||||
// description: 'Optional expiration date for this API key',
|
||||
// controlOptions: {
|
||||
// inputType: 'datetime-local',
|
||||
// },
|
||||
// }),
|
||||
],
|
||||
};
|
||||
|
||||
return slice;
|
||||
}
|
||||
|
||||
private getAvailableRoles(): Role[] {
|
||||
return [Role.ADMIN, Role.VIEWER, Role.CONNECT, Role.GUEST];
|
||||
}
|
||||
|
||||
private getRoleDescriptions(): Record<Role, string> {
|
||||
return {
|
||||
[Role.ADMIN]: 'Full administrative access to all resources',
|
||||
[Role.VIEWER]: 'Read-only access to all resources',
|
||||
[Role.CONNECT]: 'Internal Role for Unraid Connect',
|
||||
[Role.GUEST]: 'Basic read access to user profile only',
|
||||
};
|
||||
}
|
||||
|
||||
private getAvailableResources(): Resource[] {
|
||||
return Object.values(Resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert form data back to permissions for API key creation
|
||||
* The form provides: name, description, roles, and customPermissions
|
||||
* Note: permissionPresets is only a UI helper that adds to customPermissions
|
||||
*/
|
||||
convertFormDataToPermissions(formData: ApiKeyFormData): {
|
||||
roles: Role[];
|
||||
permissions: Array<{ resource: Resource; actions: AuthAction[] }>;
|
||||
} {
|
||||
const roles: Role[] = [];
|
||||
const permissions = new Map<Resource, Set<AuthAction>>();
|
||||
|
||||
// 1. Add roles if provided
|
||||
if (formData.roles && formData.roles.length > 0) {
|
||||
roles.push(...formData.roles);
|
||||
}
|
||||
|
||||
// 2. Add custom permissions if provided
|
||||
// This includes permissions added via the preset dropdown
|
||||
if (formData.customPermissions && formData.customPermissions.length > 0) {
|
||||
for (const perm of formData.customPermissions) {
|
||||
// Handle resources as an array (form uses multi-select)
|
||||
const resources = Array.isArray(perm.resources)
|
||||
? perm.resources
|
||||
: [perm.resources as Resource];
|
||||
|
||||
// Handle actions as an array and normalize them
|
||||
const rawActions = Array.isArray(perm.actions) ? perm.actions : [perm.actions];
|
||||
const normalizedActions: AuthAction[] = [];
|
||||
|
||||
for (const rawAction of rawActions) {
|
||||
const normalized = normalizeAction(rawAction);
|
||||
if (normalized) {
|
||||
normalizedActions.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
for (const resource of resources) {
|
||||
if (!permissions.has(resource)) {
|
||||
permissions.set(resource, new Set());
|
||||
}
|
||||
normalizedActions.forEach((action) => permissions.get(resource)!.add(action));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
roles,
|
||||
permissions: Array.from(permissions.entries()).map(([resource, actions]) => ({
|
||||
resource,
|
||||
actions: Array.from(actions),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Args, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
import {
|
||||
expandWildcardAction,
|
||||
mergePermissionsIntoMap,
|
||||
parseActionToAuthAction,
|
||||
} from '@unraid/shared/util/permissions.js';
|
||||
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import {
|
||||
AddPermissionInput,
|
||||
Permission,
|
||||
} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
|
||||
@Injectable()
|
||||
@Resolver()
|
||||
export class ApiKeyPermissionsResolver {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Query(() => [Permission], {
|
||||
description: 'Get the actual permissions that would be granted by a set of roles',
|
||||
})
|
||||
@UsePermissions({
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.PERMISSION,
|
||||
})
|
||||
async getPermissionsForRoles(
|
||||
@Args('roles', { type: () => [Role] }) roles: Role[]
|
||||
): Promise<Permission[]> {
|
||||
// Get the implicit permissions for each role from Casbin
|
||||
const allPermissions = new Map<Resource, Set<AuthAction>>();
|
||||
|
||||
for (const role of roles) {
|
||||
// Query Casbin for what permissions this role actually has
|
||||
const rolePermissions = await this.authService.getImplicitPermissionsForRole(role);
|
||||
mergePermissionsIntoMap(allPermissions, rolePermissions);
|
||||
}
|
||||
|
||||
// Convert to Permission array
|
||||
const permissions: Permission[] = [];
|
||||
for (const [resource, actions] of allPermissions) {
|
||||
permissions.push({
|
||||
resource,
|
||||
actions: Array.from(actions),
|
||||
});
|
||||
}
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
||||
@Query(() => [Permission], {
|
||||
description:
|
||||
'Preview the effective permissions for a combination of roles and explicit permissions',
|
||||
})
|
||||
@UsePermissions({
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.PERMISSION,
|
||||
})
|
||||
async previewEffectivePermissions(
|
||||
@Args('roles', { type: () => [Role], nullable: true }) roles?: Role[],
|
||||
@Args('permissions', { type: () => [AddPermissionInput], nullable: true })
|
||||
permissions?: AddPermissionInput[]
|
||||
): Promise<Permission[]> {
|
||||
const effectivePermissions = new Map<Resource, Set<AuthAction>>();
|
||||
|
||||
// Add permissions from roles
|
||||
for (const role of roles ?? []) {
|
||||
const rolePermissions = await this.authService.getImplicitPermissionsForRole(role);
|
||||
mergePermissionsIntoMap(effectivePermissions, rolePermissions);
|
||||
}
|
||||
|
||||
// Add explicit permissions
|
||||
if (permissions && permissions.length > 0) {
|
||||
for (const perm of permissions) {
|
||||
if (!effectivePermissions.has(perm.resource)) {
|
||||
effectivePermissions.set(perm.resource, new Set());
|
||||
}
|
||||
const resourceActions = effectivePermissions.get(perm.resource)!;
|
||||
|
||||
perm.actions.forEach((action) => {
|
||||
const actionStr = String(action);
|
||||
|
||||
// Handle wildcard - expand to all CRUD actions
|
||||
if (actionStr === '*' || actionStr.toLowerCase() === '*') {
|
||||
expandWildcardAction().forEach((expandedAction) => {
|
||||
resourceActions.add(expandedAction);
|
||||
});
|
||||
} else {
|
||||
// Use the shared helper to parse and validate the action
|
||||
const parsedAction = parseActionToAuthAction(actionStr);
|
||||
if (parsedAction) {
|
||||
resourceActions.add(parsedAction);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to Permission array
|
||||
const result: Permission[] = [];
|
||||
for (const [resource, actions] of effectivePermissions) {
|
||||
result.push({
|
||||
resource,
|
||||
actions: Array.from(actions),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Query(() => [AuthAction], {
|
||||
description: 'Get all available authentication actions with possession',
|
||||
})
|
||||
getAvailableAuthActions(): AuthAction[] {
|
||||
return Object.values(AuthAction);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Field, InputType, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { Node, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Node, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
@@ -22,15 +22,21 @@ export class Permission {
|
||||
@IsEnum(Resource)
|
||||
resource!: Resource;
|
||||
|
||||
@Field(() => [String])
|
||||
@Field(() => [AuthAction], {
|
||||
description: 'Actions allowed on this resource',
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsEnum(AuthAction, { each: true })
|
||||
@ArrayMinSize(1)
|
||||
actions!: string[];
|
||||
actions!: AuthAction[];
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class ApiKey extends Node {
|
||||
@Field()
|
||||
@IsString()
|
||||
key!: string;
|
||||
|
||||
@Field()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@@ -58,24 +64,17 @@ export class ApiKey extends Node {
|
||||
permissions!: Permission[];
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class ApiKeyWithSecret extends ApiKey {
|
||||
@Field()
|
||||
@IsString()
|
||||
key!: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class AddPermissionInput {
|
||||
@Field(() => Resource)
|
||||
@IsEnum(Resource)
|
||||
resource!: Resource;
|
||||
|
||||
@Field(() => [String])
|
||||
@Field(() => [AuthAction])
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsEnum(AuthAction, { each: true })
|
||||
@ArrayMinSize(1)
|
||||
actions!: string[];
|
||||
actions!: AuthAction[];
|
||||
}
|
||||
|
||||
@InputType()
|
||||
|
||||
@@ -3,12 +3,23 @@ import { Module } from '@nestjs/common';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AuthModule } from '@app/unraid-api/auth/auth.module.js';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { ApiKeyFormResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key-form.resolver.js';
|
||||
import { ApiKeyFormService } from '@app/unraid-api/graph/resolvers/api-key/api-key-form.service.js';
|
||||
import { ApiKeyPermissionsResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key-permissions.resolver.js';
|
||||
import { ApiKeyMutationsResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.mutation.js';
|
||||
import { ApiKeyResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.resolver.js';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
providers: [ApiKeyResolver, ApiKeyService, AuthService, ApiKeyMutationsResolver],
|
||||
exports: [ApiKeyResolver, ApiKeyService],
|
||||
providers: [
|
||||
ApiKeyResolver,
|
||||
ApiKeyService,
|
||||
AuthService,
|
||||
ApiKeyMutationsResolver,
|
||||
ApiKeyPermissionsResolver,
|
||||
ApiKeyFormService,
|
||||
ApiKeyFormResolver,
|
||||
],
|
||||
exports: [ApiKeyResolver, ApiKeyService, ApiKeyFormService],
|
||||
})
|
||||
export class ApiKeyModule {}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
|
||||
import {
|
||||
ApiKey,
|
||||
ApiKeyWithSecret,
|
||||
CreateApiKeyInput,
|
||||
DeleteApiKeyInput,
|
||||
} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
@@ -23,16 +22,7 @@ describe('ApiKeyMutationsResolver', () => {
|
||||
|
||||
const mockApiKey: ApiKey = {
|
||||
id: 'test-api-id',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
const mockApiKeyWithSecret: ApiKeyWithSecret = {
|
||||
id: 'test-api-id',
|
||||
key: 'test-api-key',
|
||||
key: 'test-secret-key',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
@@ -48,7 +38,8 @@ describe('ApiKeyMutationsResolver', () => {
|
||||
apiKeyService = new ApiKeyService();
|
||||
authzService = new AuthZService(enforcer);
|
||||
cookieService = new CookieService();
|
||||
authService = new AuthService(cookieService, apiKeyService, authzService);
|
||||
const localSessionService = { validateLocalSession: vi.fn() } as any;
|
||||
authService = new AuthService(cookieService, apiKeyService, localSessionService, authzService);
|
||||
resolver = new ApiKeyMutationsResolver(authService, apiKeyService);
|
||||
});
|
||||
|
||||
@@ -61,12 +52,12 @@ describe('ApiKeyMutationsResolver', () => {
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKeyWithSecret);
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKey);
|
||||
vi.spyOn(authService, 'syncApiKeyRoles').mockResolvedValue();
|
||||
|
||||
const result = await resolver.create(input);
|
||||
|
||||
expect(result).toEqual(mockApiKeyWithSecret);
|
||||
expect(result).toEqual(mockApiKey);
|
||||
expect(apiKeyService.create).toHaveBeenCalledWith({
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
@@ -95,7 +86,7 @@ describe('ApiKeyMutationsResolver', () => {
|
||||
roles: [Role.GUEST],
|
||||
permissions: [],
|
||||
};
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKeyWithSecret);
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKey);
|
||||
vi.spyOn(authService, 'syncApiKeyRoles').mockRejectedValue(new Error('Sync failed'));
|
||||
await expect(resolver.create(input)).rejects.toThrow('Sync failed');
|
||||
});
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import {
|
||||
AddRoleForApiKeyInput,
|
||||
ApiKeyWithSecret,
|
||||
ApiKey,
|
||||
CreateApiKeyInput,
|
||||
DeleteApiKeyInput,
|
||||
RemoveRoleFromApiKeyInput,
|
||||
UpdateApiKeyInput,
|
||||
} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { ApiKeyMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js';
|
||||
import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js';
|
||||
|
||||
@Resolver(() => ApiKeyMutations)
|
||||
export class ApiKeyMutationsResolver {
|
||||
@@ -28,12 +23,11 @@ export class ApiKeyMutationsResolver {
|
||||
) {}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.CREATE,
|
||||
action: AuthAction.CREATE_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => ApiKeyWithSecret, { description: 'Create an API key' })
|
||||
async create(@Args('input') input: CreateApiKeyInput): Promise<ApiKeyWithSecret> {
|
||||
@ResolveField(() => ApiKey, { description: 'Create an API key' })
|
||||
async create(@Args('input') input: CreateApiKeyInput): Promise<ApiKey> {
|
||||
const apiKey = await this.apiKeyService.create({
|
||||
name: input.name,
|
||||
description: input.description ?? undefined,
|
||||
@@ -46,9 +40,8 @@ export class ApiKeyMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => Boolean, { description: 'Add a role to an API key' })
|
||||
async addRole(@Args('input') input: AddRoleForApiKeyInput): Promise<boolean> {
|
||||
@@ -56,9 +49,8 @@ export class ApiKeyMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => Boolean, { description: 'Remove a role from an API key' })
|
||||
async removeRole(@Args('input') input: RemoveRoleFromApiKeyInput): Promise<boolean> {
|
||||
@@ -66,9 +58,8 @@ export class ApiKeyMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.DELETE,
|
||||
action: AuthAction.DELETE_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => Boolean, { description: 'Delete one or more API keys' })
|
||||
async delete(@Args('input') input: DeleteApiKeyInput): Promise<boolean> {
|
||||
@@ -77,12 +68,11 @@ export class ApiKeyMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => ApiKeyWithSecret, { description: 'Update an API key' })
|
||||
async update(@Args('input') input: UpdateApiKeyInput): Promise<ApiKeyWithSecret> {
|
||||
@ResolveField(() => ApiKey, { description: 'Update an API key' })
|
||||
async update(@Args('input') input: UpdateApiKeyInput): Promise<ApiKey> {
|
||||
const apiKey = await this.apiKeyService.update(input);
|
||||
await this.authService.syncApiKeyRoles(apiKey.id, apiKey.roles);
|
||||
return apiKey;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
|
||||
import { ApiKey, ApiKeyWithSecret } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { ApiKey } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { ApiKeyResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.resolver.js';
|
||||
|
||||
describe('ApiKeyResolver', () => {
|
||||
@@ -18,16 +18,7 @@ describe('ApiKeyResolver', () => {
|
||||
|
||||
const mockApiKey: ApiKey = {
|
||||
id: 'test-api-id',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
const mockApiKeyWithSecret: ApiKeyWithSecret = {
|
||||
id: 'test-api-id',
|
||||
key: 'test-api-key',
|
||||
key: 'test-secret-key',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
@@ -43,8 +34,9 @@ describe('ApiKeyResolver', () => {
|
||||
apiKeyService = new ApiKeyService();
|
||||
authzService = new AuthZService(enforcer);
|
||||
cookieService = new CookieService();
|
||||
authService = new AuthService(cookieService, apiKeyService, authzService);
|
||||
resolver = new ApiKeyResolver(authService, apiKeyService);
|
||||
const localSessionService = { validateLocalSession: vi.fn() } as any;
|
||||
authService = new AuthService(cookieService, apiKeyService, localSessionService, authzService);
|
||||
resolver = new ApiKeyResolver(apiKeyService);
|
||||
});
|
||||
|
||||
describe('apiKeys', () => {
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
import { Args, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { ApiKey, Permission } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
|
||||
@Resolver(() => ApiKey)
|
||||
export class ApiKeyResolver {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private apiKeyService: ApiKeyService
|
||||
) {}
|
||||
constructor(private apiKeyService: ApiKeyService) {}
|
||||
|
||||
@Query(() => [ApiKey])
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async apiKeys(): Promise<ApiKey[]> {
|
||||
return this.apiKeyService.findAll();
|
||||
@@ -31,9 +22,8 @@ export class ApiKeyResolver {
|
||||
|
||||
@Query(() => ApiKey, { nullable: true })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async apiKey(
|
||||
@Args('id', { type: () => PrefixedID })
|
||||
@@ -44,9 +34,8 @@ export class ApiKeyResolver {
|
||||
|
||||
@Query(() => [Role], { description: 'All possible roles for API keys' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.PERMISSION,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async apiKeyPossibleRoles(): Promise<Role[]> {
|
||||
return Object.values(Role);
|
||||
@@ -54,14 +43,13 @@ export class ApiKeyResolver {
|
||||
|
||||
@Query(() => [Permission], { description: 'All possible permissions for API keys' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.PERMISSION,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async apiKeyPossiblePermissions(): Promise<Permission[]> {
|
||||
// Build all combinations of Resource and AuthActionVerb
|
||||
// Build all combinations of Resource and AuthAction
|
||||
const resources = Object.values(Resource);
|
||||
const actions = Object.values(AuthActionVerb);
|
||||
const actions = Object.values(AuthAction);
|
||||
return resources.map((resource) => ({
|
||||
resource,
|
||||
actions,
|
||||
|
||||
@@ -5,6 +5,8 @@ import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import { IsEnum, IsInt, IsOptional, IsString } from 'class-validator';
|
||||
import { GraphQLBigInt } from 'graphql-scalars';
|
||||
|
||||
import { ParityCheck } from '@app/unraid-api/graph/resolvers/array/parity.model.js';
|
||||
|
||||
@ObjectType()
|
||||
export class Capacity {
|
||||
@Field(() => String, { description: 'Free capacity' })
|
||||
@@ -142,6 +144,9 @@ export class UnraidArray extends Node {
|
||||
@Field(() => [ArrayDisk], { description: 'Parity disks in the current array' })
|
||||
parities!: ArrayDisk[];
|
||||
|
||||
@Field(() => ParityCheck, { description: 'Current parity check status' })
|
||||
parityCheckStatus!: ParityCheck;
|
||||
|
||||
@Field(() => [ArrayDisk], { description: 'Data disks in the current array' })
|
||||
disks!: ArrayDisk[];
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Args, Mutation, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import {
|
||||
ArrayDisk,
|
||||
@@ -27,9 +23,8 @@ export class ArrayMutationsResolver {
|
||||
|
||||
@ResolveField(() => UnraidArray, { description: 'Set array state' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async setState(@Args('input') input: ArrayStateInput): Promise<UnraidArray> {
|
||||
return this.arrayService.updateArrayState(input);
|
||||
@@ -37,9 +32,8 @@ export class ArrayMutationsResolver {
|
||||
|
||||
@ResolveField(() => UnraidArray, { description: 'Add new disk to array' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async addDiskToArray(@Args('input') input: ArrayDiskInput): Promise<UnraidArray> {
|
||||
return this.arrayService.addDiskToArray(input);
|
||||
@@ -50,9 +44,8 @@ export class ArrayMutationsResolver {
|
||||
"Remove existing disk from array. NOTE: The array must be stopped before running this otherwise it'll throw an error.",
|
||||
})
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async removeDiskFromArray(@Args('input') input: ArrayDiskInput): Promise<UnraidArray> {
|
||||
return this.arrayService.removeDiskFromArray(input);
|
||||
@@ -60,9 +53,8 @@ export class ArrayMutationsResolver {
|
||||
|
||||
@ResolveField(() => ArrayDisk, { description: 'Mount a disk in the array' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async mountArrayDisk(@Args('id', { type: () => PrefixedID }) id: string): Promise<ArrayDisk> {
|
||||
const array = await this.arrayService.mountArrayDisk(id);
|
||||
@@ -80,9 +72,8 @@ export class ArrayMutationsResolver {
|
||||
|
||||
@ResolveField(() => ArrayDisk, { description: 'Unmount a disk from the array' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async unmountArrayDisk(
|
||||
@Args('id', { type: () => PrefixedID }) id: string
|
||||
@@ -102,9 +93,8 @@ export class ArrayMutationsResolver {
|
||||
|
||||
@ResolveField(() => Boolean, { description: 'Clear statistics for a disk in the array' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async clearArrayDiskStatistics(
|
||||
@Args('id', { type: () => PrefixedID }) id: string
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { UnraidArray } from '@app/unraid-api/graph/resolvers/array/array.model.js';
|
||||
@@ -17,9 +13,8 @@ export class ArrayResolver {
|
||||
|
||||
@Query(() => UnraidArray)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async array() {
|
||||
return this.arrayService.getArrayData();
|
||||
@@ -27,9 +22,8 @@ export class ArrayResolver {
|
||||
|
||||
@Subscription(() => UnraidArray)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async arraySubscription() {
|
||||
return createSubscription(PUBSUB_CHANNEL.ARRAY);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ArrayRunningError } from '@app/core/errors/array-running-error.js';
|
||||
import { getArrayData as getArrayDataUtil } from '@app/core/modules/array/get-array-data.js';
|
||||
import { ParityCheckStatus } from '@app/core/modules/array/parity-check-status.js';
|
||||
import { emcmd } from '@app/core/utils/clients/emcmd.js';
|
||||
import {
|
||||
ArrayDiskInput,
|
||||
@@ -82,6 +83,13 @@ describe('ArrayService', () => {
|
||||
parities: [],
|
||||
disks: [],
|
||||
caches: [],
|
||||
parityCheckStatus: {
|
||||
status: ParityCheckStatus.NEVER_RUN,
|
||||
progress: 0,
|
||||
date: undefined,
|
||||
duration: 0,
|
||||
speed: '0',
|
||||
},
|
||||
};
|
||||
mockGetArrayDataUtil.mockResolvedValue(mockArrayData);
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Field, GraphQLISODateTime, Int, ObjectType } from '@nestjs/graphql';
|
||||
import { Field, GraphQLISODateTime, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { ParityCheckStatus } from '@app/core/modules/array/parity-check-status.js';
|
||||
|
||||
registerEnumType(ParityCheckStatus, {
|
||||
name: 'ParityCheckStatus',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class ParityCheck {
|
||||
@@ -11,8 +17,8 @@ export class ParityCheck {
|
||||
@Field(() => String, { nullable: true, description: 'Speed of the parity check, in MB/s' })
|
||||
speed?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Status of the parity check' })
|
||||
status?: string;
|
||||
@Field(() => ParityCheckStatus, { description: 'Status of the parity check' })
|
||||
status!: ParityCheckStatus;
|
||||
|
||||
@Field(() => Int, { nullable: true, description: 'Number of errors during the parity check' })
|
||||
errors?: number;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Args, Mutation, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
import { GraphQLJSON } from 'graphql-scalars';
|
||||
|
||||
import { ParityService } from '@app/unraid-api/graph/resolvers/array/parity.service.js';
|
||||
@@ -19,9 +15,8 @@ export class ParityCheckMutationsResolver {
|
||||
constructor(private readonly parityService: ParityService) {}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => GraphQLJSON, { description: 'Start a parity check' })
|
||||
async start(@Args('correct') correct: boolean): Promise<object> {
|
||||
@@ -32,9 +27,8 @@ export class ParityCheckMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => GraphQLJSON, { description: 'Pause a parity check' })
|
||||
async pause(): Promise<object> {
|
||||
@@ -45,9 +39,8 @@ export class ParityCheckMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => GraphQLJSON, { description: 'Resume a parity check' })
|
||||
async resume(): Promise<object> {
|
||||
@@ -58,9 +51,8 @@ export class ParityCheckMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => GraphQLJSON, { description: 'Cancel a parity check' })
|
||||
async cancel(): Promise<object> {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
import { PubSub } from 'graphql-subscriptions';
|
||||
|
||||
import { PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
@@ -23,9 +19,8 @@ export class ParityResolver {
|
||||
) {}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Query(() => [ParityCheck])
|
||||
async parityHistory(): Promise<ParityCheck[]> {
|
||||
@@ -33,9 +28,8 @@ export class ParityResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Subscription(() => ParityCheck)
|
||||
parityHistorySubscription() {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
import { toNumberAlways } from '@unraid/shared/util/data.js';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { ParityCheckStatus } from '@app/core/modules/array/parity-check-status.js';
|
||||
import { emcmd } from '@app/core/utils/index.js';
|
||||
import { ParityCheck } from '@app/unraid-api/graph/resolvers/array/parity.model.js';
|
||||
|
||||
@@ -22,16 +24,30 @@ export class ParityService {
|
||||
const lines = history.toString().trim().split('\n').reverse();
|
||||
return lines.map<ParityCheck>((line) => {
|
||||
const [date, duration, speed, status, errors = '0'] = line.split('|');
|
||||
const parsedDate = new Date(date);
|
||||
const safeDate = Number.isNaN(parsedDate.getTime()) ? undefined : parsedDate;
|
||||
const durationNumber = Number(duration);
|
||||
const safeDuration = Number.isNaN(durationNumber) ? undefined : durationNumber;
|
||||
return {
|
||||
date: new Date(date),
|
||||
duration: Number.parseInt(duration, 10),
|
||||
date: safeDate,
|
||||
duration: safeDuration,
|
||||
speed: speed ?? 'Unavailable',
|
||||
status: status === '-4' ? 'Cancelled' : 'OK',
|
||||
// use http 422 (unprocessable entity) as fallback to differentiate from unix error codes
|
||||
// when status is not a number.
|
||||
status: this.statusCodeToStatusEnum(toNumberAlways(status, 422)),
|
||||
errors: Number.parseInt(errors, 10),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
statusCodeToStatusEnum(statusCode: number): ParityCheckStatus {
|
||||
return statusCode === -4
|
||||
? ParityCheckStatus.CANCELLED
|
||||
: toNumberAlways(statusCode, 0) === 0
|
||||
? ParityCheckStatus.COMPLETED
|
||||
: ParityCheckStatus.FAILED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the parity check state
|
||||
* @param wantedState - The desired state for the parity check ('pause', 'resume', 'cancel', 'start')
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { Config } from '@app/unraid-api/graph/resolvers/config/config.model.js';
|
||||
@@ -14,9 +10,8 @@ import { Config } from '@app/unraid-api/graph/resolvers/config/config.model.js';
|
||||
export class ConfigResolver {
|
||||
@Query(() => Config)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.CONFIG,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async config(): Promise<Config> {
|
||||
const emhttp = getters.emhttp();
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { Public } from '@app/unraid-api/auth/public.decorator.js'; // Import Public decorator
|
||||
|
||||
@@ -23,9 +19,8 @@ export class CustomizationResolver {
|
||||
// Authenticated query
|
||||
@Query(() => Customization, { nullable: true })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.CUSTOMIZATIONS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async customization(): Promise<Customization | null> {
|
||||
// We return an empty object because the fields are resolved by @ResolveField
|
||||
@@ -52,9 +47,8 @@ export class CustomizationResolver {
|
||||
|
||||
@ResolveField(() => ActivationCode, { nullable: true, name: 'activationCode' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.ACTIVATION_CODE,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async activationCode(): Promise<ActivationCode | null> {
|
||||
return this.customizationService.getActivationData();
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { Args, Int, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { Disk } from '@app/unraid-api/graph/resolvers/disks/disks.model.js';
|
||||
import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js';
|
||||
@@ -17,9 +13,8 @@ export class DisksResolver {
|
||||
|
||||
@Query(() => [Disk])
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DISK,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async disks() {
|
||||
return this.disksService.getDisks();
|
||||
@@ -27,9 +22,8 @@ export class DisksResolver {
|
||||
|
||||
@Query(() => Disk)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DISK,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async disk(@Args('id', { type: () => PrefixedID }) id: string) {
|
||||
return this.disksService.getDisk(id);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Test } from '@nestjs/testing';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DisplayResolver } from '@app/unraid-api/graph/resolvers/display/display.resolver.js';
|
||||
import { DisplayService } from '@app/unraid-api/graph/resolvers/display/display.service.js';
|
||||
import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js';
|
||||
|
||||
// Mock the pubsub module
|
||||
vi.mock('@app/core/pubsub.js', () => ({
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
import { Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { DisplayService } from '@app/unraid-api/graph/resolvers/display/display.service.js';
|
||||
import { Display } from '@app/unraid-api/graph/resolvers/info/info.model.js';
|
||||
import { Display } from '@app/unraid-api/graph/resolvers/info/display/display.model.js';
|
||||
import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js';
|
||||
|
||||
@Resolver(() => Display)
|
||||
export class DisplayResolver {
|
||||
constructor(private readonly displayService: DisplayService) {}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DISPLAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Query(() => Display)
|
||||
public async display(): Promise<Display> {
|
||||
@@ -27,9 +22,8 @@ export class DisplayResolver {
|
||||
|
||||
@Subscription(() => Display)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DISPLAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async displaySubscription() {
|
||||
return createSubscription(PUBSUB_CHANNEL.DISPLAY);
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
|
||||
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
|
||||
@@ -21,9 +17,8 @@ export class DockerMutationsResolver {
|
||||
|
||||
@ResolveField(() => DockerContainer, { description: 'Start a container' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async start(@Args('id', { type: () => PrefixedID }) id: string) {
|
||||
return this.dockerService.start(id);
|
||||
@@ -31,9 +26,8 @@ export class DockerMutationsResolver {
|
||||
|
||||
@ResolveField(() => DockerContainer, { description: 'Stop a container' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async stop(@Args('id', { type: () => PrefixedID }) id: string) {
|
||||
return this.dockerService.stop(id);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/docker-organizer.service.js';
|
||||
import {
|
||||
@@ -25,9 +21,8 @@ export class DockerResolver {
|
||||
) {}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Query(() => Docker)
|
||||
public docker() {
|
||||
@@ -37,9 +32,8 @@ export class DockerResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => [DockerContainer])
|
||||
public async containers(
|
||||
@@ -49,9 +43,8 @@ export class DockerResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => [DockerNetwork])
|
||||
public async networks(
|
||||
@@ -61,9 +54,8 @@ export class DockerResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => ResolvedOrganizerV1)
|
||||
public async organizer() {
|
||||
@@ -71,9 +63,8 @@ export class DockerResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Mutation(() => ResolvedOrganizerV1)
|
||||
public async createDockerFolder(
|
||||
@@ -90,9 +81,8 @@ export class DockerResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Mutation(() => ResolvedOrganizerV1)
|
||||
public async setDockerFolderChildren(
|
||||
@@ -107,9 +97,8 @@ export class DockerResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Mutation(() => ResolvedOrganizerV1)
|
||||
public async deleteDockerEntries(@Args('entryIds', { type: () => [String] }) entryIds: string[]) {
|
||||
@@ -120,9 +109,8 @@ export class DockerResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Mutation(() => ResolvedOrganizerV1)
|
||||
public async moveDockerEntriesToFolder(
|
||||
|
||||
@@ -25,7 +25,7 @@ interface NetworkListingOptions {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DockerService implements OnModuleInit {
|
||||
export class DockerService {
|
||||
private client: Docker;
|
||||
private autoStarts: string[] = [];
|
||||
private readonly logger = new Logger(DockerService.name);
|
||||
@@ -57,19 +57,6 @@ export class DockerService implements OnModuleInit {
|
||||
};
|
||||
}
|
||||
|
||||
public async onModuleInit() {
|
||||
try {
|
||||
await this.getContainers({ skipCache: true });
|
||||
await this.getNetworks({ skipCache: true });
|
||||
this.logger.debug('Docker cache warming complete.');
|
||||
const appInfo = await this.getAppInfo();
|
||||
await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo);
|
||||
} catch (error) {
|
||||
this.logger.warn('Error initializing Docker module:', error);
|
||||
this.logger.warn('Docker may be disabled under Settings -> Docker.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Docker auto start file
|
||||
*
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { Flash } from '@app/unraid-api/graph/resolvers/flash/flash.model.js';
|
||||
@@ -14,9 +10,8 @@ import { Flash } from '@app/unraid-api/graph/resolvers/flash/flash.model.js';
|
||||
export class FlashResolver {
|
||||
@Query(() => Flash)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.FLASH,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async flash() {
|
||||
const emhttp = getters.emhttp();
|
||||
|
||||
93
api/src/unraid-api/graph/resolvers/info/cpu/cpu.model.ts
Normal file
93
api/src/unraid-api/graph/resolvers/info/cpu/cpu.model.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Field, Float, Int, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { Node } from '@unraid/shared/graphql.model.js';
|
||||
import { GraphQLJSON } from 'graphql-scalars';
|
||||
|
||||
@ObjectType({ description: 'CPU load for a single core' })
|
||||
export class CpuLoad {
|
||||
@Field(() => Float, { description: 'The total CPU load on a single core, in percent.' })
|
||||
percentTotal!: number;
|
||||
|
||||
@Field(() => Float, { description: 'The percentage of time the CPU spent in user space.' })
|
||||
percentUser!: number;
|
||||
|
||||
@Field(() => Float, { description: 'The percentage of time the CPU spent in kernel space.' })
|
||||
percentSystem!: number;
|
||||
|
||||
@Field(() => Float, {
|
||||
description:
|
||||
'The percentage of time the CPU spent on low-priority (niced) user space processes.',
|
||||
})
|
||||
percentNice!: number;
|
||||
|
||||
@Field(() => Float, { description: 'The percentage of time the CPU was idle.' })
|
||||
percentIdle!: number;
|
||||
|
||||
@Field(() => Float, {
|
||||
description: 'The percentage of time the CPU spent servicing hardware interrupts.',
|
||||
})
|
||||
percentIrq!: number;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class CpuUtilization extends Node {
|
||||
@Field(() => Float, { description: 'Total CPU load in percent' })
|
||||
percentTotal!: number;
|
||||
|
||||
@Field(() => [CpuLoad], { description: 'CPU load for each core' })
|
||||
cpus!: CpuLoad[];
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoCpu extends Node {
|
||||
@Field(() => String, { nullable: true, description: 'CPU manufacturer' })
|
||||
manufacturer?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'CPU brand name' })
|
||||
brand?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'CPU vendor' })
|
||||
vendor?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'CPU family' })
|
||||
family?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'CPU model' })
|
||||
model?: string;
|
||||
|
||||
@Field(() => Int, { nullable: true, description: 'CPU stepping' })
|
||||
stepping?: number;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'CPU revision' })
|
||||
revision?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'CPU voltage' })
|
||||
voltage?: string;
|
||||
|
||||
@Field(() => Float, { nullable: true, description: 'Current CPU speed in GHz' })
|
||||
speed?: number;
|
||||
|
||||
@Field(() => Float, { nullable: true, description: 'Minimum CPU speed in GHz' })
|
||||
speedmin?: number;
|
||||
|
||||
@Field(() => Float, { nullable: true, description: 'Maximum CPU speed in GHz' })
|
||||
speedmax?: number;
|
||||
|
||||
@Field(() => Int, { nullable: true, description: 'Number of CPU threads' })
|
||||
threads?: number;
|
||||
|
||||
@Field(() => Int, { nullable: true, description: 'Number of CPU cores' })
|
||||
cores?: number;
|
||||
|
||||
@Field(() => Int, { nullable: true, description: 'Number of physical processors' })
|
||||
processors?: number;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'CPU socket type' })
|
||||
socket?: string;
|
||||
|
||||
@Field(() => GraphQLJSON, { nullable: true, description: 'CPU cache information' })
|
||||
cache?: Record<string, any>;
|
||||
|
||||
@Field(() => [String], { nullable: true, description: 'CPU feature flags' })
|
||||
flags?: string[];
|
||||
}
|
||||
43
api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.ts
Normal file
43
api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { cpu, cpuFlags, currentLoad } from 'systeminformation';
|
||||
|
||||
import { CpuUtilization, InfoCpu } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js';
|
||||
|
||||
@Injectable()
|
||||
export class CpuService {
|
||||
async generateCpu(): Promise<InfoCpu> {
|
||||
const { cores, physicalCores, speedMin, speedMax, stepping, ...rest } = await cpu();
|
||||
const flags = await cpuFlags()
|
||||
.then((flags) => flags.split(' '))
|
||||
.catch(() => []);
|
||||
|
||||
return {
|
||||
id: 'info/cpu',
|
||||
...rest,
|
||||
cores: physicalCores,
|
||||
threads: cores,
|
||||
flags,
|
||||
stepping: Number(stepping),
|
||||
speedmin: speedMin || -1,
|
||||
speedmax: speedMax || -1,
|
||||
};
|
||||
}
|
||||
|
||||
async generateCpuLoad(): Promise<CpuUtilization> {
|
||||
const loadData = await currentLoad();
|
||||
|
||||
return {
|
||||
id: 'info/cpu-load',
|
||||
percentTotal: loadData.currentLoad,
|
||||
cpus: loadData.cpus.map((cpu) => ({
|
||||
percentTotal: cpu.load,
|
||||
percentUser: cpu.loadUser,
|
||||
percentSystem: cpu.loadSystem,
|
||||
percentNice: cpu.loadNice,
|
||||
percentIdle: cpu.loadIdle,
|
||||
percentIrq: cpu.loadIrq,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices.service.js';
|
||||
import { Devices, Gpu, Pci, Usb } from '@app/unraid-api/graph/resolvers/info/info.model.js';
|
||||
|
||||
@Resolver(() => Devices)
|
||||
export class DevicesResolver {
|
||||
constructor(private readonly devicesService: DevicesService) {}
|
||||
|
||||
@ResolveField(() => [Gpu])
|
||||
public async gpu(): Promise<Gpu[]> {
|
||||
return this.devicesService.generateGpu();
|
||||
}
|
||||
|
||||
@ResolveField(() => [Pci])
|
||||
public async pci(): Promise<Pci[]> {
|
||||
return this.devicesService.generatePci();
|
||||
}
|
||||
|
||||
@ResolveField(() => [Usb])
|
||||
public async usb(): Promise<Usb[]> {
|
||||
return this.devicesService.generateUsb();
|
||||
}
|
||||
}
|
||||
102
api/src/unraid-api/graph/resolvers/info/devices/devices.model.ts
Normal file
102
api/src/unraid-api/graph/resolvers/info/devices/devices.model.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { Node } from '@unraid/shared/graphql.model.js';
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoGpu extends Node {
|
||||
@Field(() => String, { description: 'GPU type/manufacturer' })
|
||||
type!: string;
|
||||
|
||||
@Field(() => String, { description: 'GPU type identifier' })
|
||||
typeid!: string;
|
||||
|
||||
@Field(() => Boolean, { description: 'Whether GPU is blacklisted' })
|
||||
blacklisted!: boolean;
|
||||
|
||||
@Field(() => String, { description: 'Device class' })
|
||||
class!: string;
|
||||
|
||||
@Field(() => String, { description: 'Product ID' })
|
||||
productid!: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Vendor name' })
|
||||
vendorname?: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoNetwork extends Node {
|
||||
@Field(() => String, { description: 'Network interface name' })
|
||||
iface!: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Network interface model' })
|
||||
model?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Network vendor' })
|
||||
vendor?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'MAC address' })
|
||||
mac?: string;
|
||||
|
||||
@Field(() => Boolean, { nullable: true, description: 'Virtual interface flag' })
|
||||
virtual?: boolean;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Network speed' })
|
||||
speed?: string;
|
||||
|
||||
@Field(() => Boolean, { nullable: true, description: 'DHCP enabled flag' })
|
||||
dhcp?: boolean;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoPci extends Node {
|
||||
@Field(() => String, { description: 'Device type/manufacturer' })
|
||||
type!: string;
|
||||
|
||||
@Field(() => String, { description: 'Type identifier' })
|
||||
typeid!: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Vendor name' })
|
||||
vendorname?: string;
|
||||
|
||||
@Field(() => String, { description: 'Vendor ID' })
|
||||
vendorid!: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Product name' })
|
||||
productname?: string;
|
||||
|
||||
@Field(() => String, { description: 'Product ID' })
|
||||
productid!: string;
|
||||
|
||||
@Field(() => String, { description: 'Blacklisted status' })
|
||||
blacklisted!: string;
|
||||
|
||||
@Field(() => String, { description: 'Device class' })
|
||||
class!: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoUsb extends Node {
|
||||
@Field(() => String, { description: 'USB device name' })
|
||||
name!: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'USB bus number' })
|
||||
bus?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'USB device number' })
|
||||
device?: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoDevices extends Node {
|
||||
@Field(() => [InfoGpu], { nullable: true, description: 'List of GPU devices' })
|
||||
gpu?: InfoGpu[];
|
||||
|
||||
@Field(() => [InfoNetwork], { nullable: true, description: 'List of network interfaces' })
|
||||
network?: InfoNetwork[];
|
||||
|
||||
@Field(() => [InfoPci], { nullable: true, description: 'List of PCI devices' })
|
||||
pci?: InfoPci[];
|
||||
|
||||
@Field(() => [InfoUsb], { nullable: true, description: 'List of USB devices' })
|
||||
usb?: InfoUsb[];
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { Test } from '@nestjs/testing';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DevicesResolver } from '@app/unraid-api/graph/resolvers/info/devices.resolver.js';
|
||||
import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices.service.js';
|
||||
import { DevicesResolver } from '@app/unraid-api/graph/resolvers/info/devices/devices.resolver.js';
|
||||
import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices/devices.service.js';
|
||||
|
||||
describe('DevicesResolver', () => {
|
||||
let resolver: DevicesResolver;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
InfoDevices,
|
||||
InfoGpu,
|
||||
InfoNetwork,
|
||||
InfoPci,
|
||||
InfoUsb,
|
||||
} from '@app/unraid-api/graph/resolvers/info/devices/devices.model.js';
|
||||
import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices/devices.service.js';
|
||||
|
||||
@Resolver(() => InfoDevices)
|
||||
export class DevicesResolver {
|
||||
constructor(private readonly devicesService: DevicesService) {}
|
||||
|
||||
@ResolveField(() => [InfoGpu])
|
||||
public async gpu(): Promise<InfoGpu[]> {
|
||||
return this.devicesService.generateGpu();
|
||||
}
|
||||
|
||||
@ResolveField(() => [InfoNetwork])
|
||||
public async network(): Promise<InfoNetwork[]> {
|
||||
return this.devicesService.generateNetwork();
|
||||
}
|
||||
|
||||
@ResolveField(() => [InfoPci])
|
||||
public async pci(): Promise<InfoPci[]> {
|
||||
return this.devicesService.generatePci();
|
||||
}
|
||||
|
||||
@ResolveField(() => [InfoUsb])
|
||||
public async usb(): Promise<InfoUsb[]> {
|
||||
return this.devicesService.generateUsb();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Test } from '@nestjs/testing';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices.service.js';
|
||||
import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices/devices.service.js';
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock('fs/promises', () => ({
|
||||
@@ -13,24 +13,35 @@ import { filterDevices } from '@app/core/utils/vms/filter-devices.js';
|
||||
import { getPciDevices } from '@app/core/utils/vms/get-pci-devices.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
import {
|
||||
Gpu,
|
||||
Pci,
|
||||
RawUsbDeviceData,
|
||||
Usb,
|
||||
UsbDevice,
|
||||
} from '@app/unraid-api/graph/resolvers/info/info.model.js';
|
||||
InfoGpu,
|
||||
InfoNetwork,
|
||||
InfoPci,
|
||||
InfoUsb,
|
||||
} from '@app/unraid-api/graph/resolvers/info/devices/devices.model.js';
|
||||
|
||||
interface RawUsbDeviceData {
|
||||
id: string;
|
||||
n?: string;
|
||||
}
|
||||
|
||||
interface UsbDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
guid: string;
|
||||
vendorname?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DevicesService {
|
||||
private readonly logger = new Logger(DevicesService.name);
|
||||
|
||||
async generateGpu(): Promise<Gpu[]> {
|
||||
async generateGpu(): Promise<InfoGpu[]> {
|
||||
try {
|
||||
const systemPciDevices = await this.getSystemPciDevices();
|
||||
return systemPciDevices
|
||||
.filter((device) => device.class === 'vga' && !device.allowed)
|
||||
.map((entry) => {
|
||||
const gpu: Gpu = {
|
||||
const gpu: InfoGpu = {
|
||||
id: `gpu/${entry.id}`,
|
||||
blacklisted: entry.allowed,
|
||||
class: entry.class,
|
||||
@@ -50,7 +61,7 @@ export class DevicesService {
|
||||
}
|
||||
}
|
||||
|
||||
async generatePci(): Promise<Pci[]> {
|
||||
async generatePci(): Promise<InfoPci[]> {
|
||||
try {
|
||||
const devices = await this.getSystemPciDevices();
|
||||
return devices.map((device) => ({
|
||||
@@ -73,7 +84,21 @@ export class DevicesService {
|
||||
}
|
||||
}
|
||||
|
||||
async generateUsb(): Promise<Usb[]> {
|
||||
async generateNetwork(): Promise<InfoNetwork[]> {
|
||||
try {
|
||||
// For now, return empty array. This can be implemented later to fetch actual network interfaces
|
||||
// using systeminformation or similar libraries
|
||||
return [];
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(
|
||||
`Failed to generate network devices: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async generateUsb(): Promise<InfoUsb[]> {
|
||||
try {
|
||||
const usbDevices = await this.getSystemUSBDevices();
|
||||
return usbDevices.map((device) => ({
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Field, Float, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { Node } from '@unraid/shared/graphql.model.js';
|
||||
|
||||
import { ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js';
|
||||
|
||||
export enum Temperature {
|
||||
CELSIUS = 'C',
|
||||
FAHRENHEIT = 'F',
|
||||
}
|
||||
|
||||
registerEnumType(Temperature, {
|
||||
name: 'Temperature',
|
||||
description: 'Temperature unit',
|
||||
});
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoDisplayCase extends Node {
|
||||
@Field(() => String, { description: 'Case image URL' })
|
||||
url!: string;
|
||||
|
||||
@Field(() => String, { description: 'Case icon identifier' })
|
||||
icon!: string;
|
||||
|
||||
@Field(() => String, { description: 'Error message if any' })
|
||||
error!: string;
|
||||
|
||||
@Field(() => String, { description: 'Base64 encoded case image' })
|
||||
base64!: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoDisplay extends Node {
|
||||
@Field(() => InfoDisplayCase, { description: 'Case display configuration' })
|
||||
case!: InfoDisplayCase;
|
||||
|
||||
@Field(() => ThemeName, { description: 'UI theme name' })
|
||||
theme!: ThemeName;
|
||||
|
||||
@Field(() => Temperature, { description: 'Temperature unit (C or F)' })
|
||||
unit!: Temperature;
|
||||
|
||||
@Field(() => Boolean, { description: 'Enable UI scaling' })
|
||||
scale!: boolean;
|
||||
|
||||
@Field(() => Boolean, { description: 'Show tabs in UI' })
|
||||
tabs!: boolean;
|
||||
|
||||
@Field(() => Boolean, { description: 'Enable UI resize' })
|
||||
resize!: boolean;
|
||||
|
||||
@Field(() => Boolean, { description: 'Show WWN identifiers' })
|
||||
wwn!: boolean;
|
||||
|
||||
@Field(() => Boolean, { description: 'Show totals' })
|
||||
total!: boolean;
|
||||
|
||||
@Field(() => Boolean, { description: 'Show usage statistics' })
|
||||
usage!: boolean;
|
||||
|
||||
@Field(() => Boolean, { description: 'Show text labels' })
|
||||
text!: boolean;
|
||||
|
||||
@Field(() => Int, { description: 'Warning temperature threshold' })
|
||||
warning!: number;
|
||||
|
||||
@Field(() => Int, { description: 'Critical temperature threshold' })
|
||||
critical!: number;
|
||||
|
||||
@Field(() => Int, { description: 'Hot temperature threshold' })
|
||||
hot!: number;
|
||||
|
||||
@Field(() => Int, { nullable: true, description: 'Maximum temperature threshold' })
|
||||
max?: number;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Locale setting' })
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
// Export aliases for backward compatibility with the main DisplayResolver
|
||||
export { InfoDisplay as Display };
|
||||
export { InfoDisplayCase as DisplayCase };
|
||||
@@ -3,7 +3,7 @@ import { Test } from '@nestjs/testing';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DisplayService } from '@app/unraid-api/graph/resolvers/display/display.service.js';
|
||||
import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js';
|
||||
|
||||
// Mock fs/promises at the module level only for specific test cases
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
@@ -37,7 +37,7 @@ describe('DisplayService', () => {
|
||||
const result = await service.generateDisplay();
|
||||
|
||||
// Verify basic structure
|
||||
expect(result).toHaveProperty('id', 'display');
|
||||
expect(result).toHaveProperty('id', 'info/display');
|
||||
expect(result).toHaveProperty('case');
|
||||
expect(result.case).toHaveProperty('url');
|
||||
expect(result.case).toHaveProperty('icon');
|
||||
@@ -69,6 +69,7 @@ describe('DisplayService', () => {
|
||||
const result = await service.generateDisplay();
|
||||
|
||||
expect(result.case).toEqual({
|
||||
id: 'display/case',
|
||||
url: '',
|
||||
icon: 'custom',
|
||||
error: 'could-not-read-config-file',
|
||||
@@ -90,7 +91,7 @@ describe('DisplayService', () => {
|
||||
const result = await service.generateDisplay();
|
||||
|
||||
// Should still return basic structure even if some config is missing
|
||||
expect(result).toHaveProperty('id', 'display');
|
||||
expect(result).toHaveProperty('id', 'info/display');
|
||||
expect(result).toHaveProperty('case');
|
||||
// The actual config depends on what's in the dev files
|
||||
});
|
||||
@@ -114,11 +115,6 @@ describe('DisplayService', () => {
|
||||
expect(result.critical).toBe(90);
|
||||
expect(result.hot).toBe(45);
|
||||
expect(result.max).toBe(55);
|
||||
expect(result.date).toBe('%c');
|
||||
expect(result.number).toBe('.,');
|
||||
expect(result.users).toBe('Tasks:3');
|
||||
expect(result.banner).toBe('image');
|
||||
expect(result.dashapps).toBe('icons');
|
||||
expect(result.locale).toBe('en_US'); // default fallback when not specified
|
||||
});
|
||||
|
||||
@@ -140,6 +136,7 @@ describe('DisplayService', () => {
|
||||
const result = await service.generateDisplay();
|
||||
|
||||
expect(result.case).toEqual({
|
||||
id: 'display/case',
|
||||
url: '',
|
||||
icon: 'default',
|
||||
error: '',
|
||||
@@ -6,19 +6,22 @@ import { type DynamixConfig } from '@app/core/types/ini.js';
|
||||
import { toBoolean } from '@app/core/utils/casting.js';
|
||||
import { fileExists } from '@app/core/utils/files/file-exists.js';
|
||||
import { loadState } from '@app/core/utils/misc/load-state.js';
|
||||
import { validateEnumValue } from '@app/core/utils/validation/enum-validator.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js';
|
||||
import { Display, Temperature } from '@app/unraid-api/graph/resolvers/info/info.model.js';
|
||||
import { Display, Temperature } from '@app/unraid-api/graph/resolvers/info/display/display.model.js';
|
||||
|
||||
const states = {
|
||||
// Success
|
||||
custom: {
|
||||
id: 'display/case',
|
||||
url: '',
|
||||
icon: 'custom',
|
||||
error: '',
|
||||
base64: '',
|
||||
},
|
||||
default: {
|
||||
id: 'display/case',
|
||||
url: '',
|
||||
icon: 'default',
|
||||
error: '',
|
||||
@@ -27,30 +30,35 @@ const states = {
|
||||
|
||||
// Errors
|
||||
couldNotReadConfigFile: {
|
||||
id: 'display/case',
|
||||
url: '',
|
||||
icon: 'custom',
|
||||
error: 'could-not-read-config-file',
|
||||
base64: '',
|
||||
},
|
||||
couldNotReadImage: {
|
||||
id: 'display/case',
|
||||
url: '',
|
||||
icon: 'custom',
|
||||
error: 'could-not-read-image',
|
||||
base64: '',
|
||||
},
|
||||
imageMissing: {
|
||||
id: 'display/case',
|
||||
url: '',
|
||||
icon: 'custom',
|
||||
error: 'image-missing',
|
||||
base64: '',
|
||||
},
|
||||
imageTooBig: {
|
||||
id: 'display/case',
|
||||
url: '',
|
||||
icon: 'custom',
|
||||
error: 'image-too-big',
|
||||
base64: '',
|
||||
},
|
||||
imageCorrupt: {
|
||||
id: 'display/case',
|
||||
url: '',
|
||||
icon: 'custom',
|
||||
error: 'image-corrupt',
|
||||
@@ -67,11 +75,26 @@ export class DisplayService {
|
||||
// Get display configuration
|
||||
const config = await this.getDisplayConfig();
|
||||
|
||||
return {
|
||||
id: 'display',
|
||||
const display: Display = {
|
||||
id: 'info/display',
|
||||
case: caseInfo,
|
||||
...config,
|
||||
theme: config.theme ?? ThemeName.white,
|
||||
unit: config.unit ?? Temperature.CELSIUS,
|
||||
scale: config.scale ?? false,
|
||||
tabs: config.tabs ?? true,
|
||||
resize: config.resize ?? true,
|
||||
wwn: config.wwn ?? false,
|
||||
total: config.total ?? true,
|
||||
usage: config.usage ?? true,
|
||||
text: config.text ?? true,
|
||||
warning: config.warning ?? 60,
|
||||
critical: config.critical ?? 80,
|
||||
hot: config.hot ?? 90,
|
||||
max: config.max,
|
||||
locale: config.locale,
|
||||
};
|
||||
|
||||
return display;
|
||||
}
|
||||
|
||||
private async getCaseInfo() {
|
||||
@@ -102,11 +125,12 @@ export class DisplayService {
|
||||
// Non-custom icon
|
||||
return {
|
||||
...states.default,
|
||||
id: 'display/case',
|
||||
icon: serverCase,
|
||||
};
|
||||
}
|
||||
|
||||
private async getDisplayConfig() {
|
||||
private async getDisplayConfig(): Promise<Partial<Omit<Display, 'id' | 'case'>>> {
|
||||
const filePaths = getters.paths()['dynamix-config'];
|
||||
|
||||
const state = filePaths.reduce<Partial<DynamixConfig>>((acc, filePath) => {
|
||||
@@ -122,10 +146,11 @@ export class DisplayService {
|
||||
}
|
||||
|
||||
const { theme, unit, ...display } = state.display;
|
||||
|
||||
return {
|
||||
...display,
|
||||
theme: theme as ThemeName,
|
||||
unit: unit as Temperature,
|
||||
theme: validateEnumValue(theme, ThemeName),
|
||||
unit: validateEnumValue(unit, Temperature),
|
||||
scale: toBoolean(display.scale),
|
||||
tabs: toBoolean(display.tabs),
|
||||
resize: toBoolean(display.resize),
|
||||
@@ -1,552 +1,44 @@
|
||||
import {
|
||||
Field,
|
||||
Float,
|
||||
GraphQLISODateTime,
|
||||
ID,
|
||||
Int,
|
||||
ObjectType,
|
||||
registerEnumType,
|
||||
} from '@nestjs/graphql';
|
||||
import { Field, GraphQLISODateTime, ID, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { Node } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import { GraphQLBigInt, GraphQLJSON } from 'graphql-scalars';
|
||||
|
||||
import { ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js';
|
||||
|
||||
// USB device interface for type safety
|
||||
export interface UsbDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
guid: string;
|
||||
vendorname: string;
|
||||
}
|
||||
|
||||
// Raw USB device data from lsusb parsing
|
||||
export interface RawUsbDeviceData {
|
||||
id: string;
|
||||
n?: string;
|
||||
}
|
||||
|
||||
export enum Temperature {
|
||||
C = 'C',
|
||||
F = 'F',
|
||||
}
|
||||
|
||||
registerEnumType(Temperature, {
|
||||
name: 'Temperature',
|
||||
description: 'Temperature unit (Celsius or Fahrenheit)',
|
||||
});
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoApps extends Node {
|
||||
@Field(() => Int, { description: 'How many docker containers are installed' })
|
||||
installed!: number;
|
||||
|
||||
@Field(() => Int, { description: 'How many docker containers are running' })
|
||||
started!: number;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class Baseboard extends Node {
|
||||
@Field(() => String)
|
||||
manufacturer!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
model?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
version?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
serial?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
assetTag?: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoCpu extends Node {
|
||||
@Field(() => String)
|
||||
manufacturer!: string;
|
||||
|
||||
@Field(() => String)
|
||||
brand!: string;
|
||||
|
||||
@Field(() => String)
|
||||
vendor!: string;
|
||||
|
||||
@Field(() => String)
|
||||
family!: string;
|
||||
|
||||
@Field(() => String)
|
||||
model!: string;
|
||||
|
||||
@Field(() => Int)
|
||||
stepping!: number;
|
||||
|
||||
@Field(() => String)
|
||||
revision!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
voltage?: string;
|
||||
|
||||
@Field(() => Float)
|
||||
speed!: number;
|
||||
|
||||
@Field(() => Float)
|
||||
speedmin!: number;
|
||||
|
||||
@Field(() => Float)
|
||||
speedmax!: number;
|
||||
|
||||
@Field(() => Int)
|
||||
threads!: number;
|
||||
|
||||
@Field(() => Int)
|
||||
cores!: number;
|
||||
|
||||
@Field(() => Int)
|
||||
processors!: number;
|
||||
|
||||
@Field(() => String)
|
||||
socket!: string;
|
||||
|
||||
@Field(() => GraphQLJSON)
|
||||
cache!: Record<string, any>;
|
||||
|
||||
@Field(() => [String])
|
||||
flags!: string[];
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class Gpu extends Node {
|
||||
@Field(() => String)
|
||||
type!: string;
|
||||
|
||||
@Field(() => String)
|
||||
typeid!: string;
|
||||
|
||||
@Field(() => String)
|
||||
vendorname!: string;
|
||||
|
||||
@Field(() => String)
|
||||
productid!: string;
|
||||
|
||||
@Field(() => Boolean)
|
||||
blacklisted!: boolean;
|
||||
|
||||
@Field(() => String)
|
||||
class!: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class Network extends Node {
|
||||
@Field(() => String, { nullable: true })
|
||||
iface?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
ifaceName?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
ipv4?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
ipv6?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
mac?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
internal?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
operstate?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
type?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
duplex?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
mtu?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
speed?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
carrierChanges?: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class Pci extends Node {
|
||||
@Field(() => String, { nullable: true })
|
||||
type?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
typeid?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
vendorname?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
vendorid?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
productname?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
productid?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
blacklisted?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
class?: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class Usb extends Node {
|
||||
@Field(() => String, { nullable: true })
|
||||
name?: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class Devices extends Node {
|
||||
@Field(() => [Gpu])
|
||||
gpu!: Gpu[];
|
||||
|
||||
@Field(() => [Pci])
|
||||
pci!: Pci[];
|
||||
|
||||
@Field(() => [Usb])
|
||||
usb!: Usb[];
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class Case {
|
||||
@Field(() => String, { nullable: true })
|
||||
icon?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
url?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
error?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
base64?: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class Display extends Node {
|
||||
@Field(() => Case, { nullable: true })
|
||||
case?: Case;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
date?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
number?: string;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
scale?: boolean;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
tabs?: boolean;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
users?: string;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
resize?: boolean;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
wwn?: boolean;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
total?: boolean;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
usage?: boolean;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
banner?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
dashapps?: string;
|
||||
|
||||
@Field(() => ThemeName, { nullable: true })
|
||||
theme?: ThemeName;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
text?: boolean;
|
||||
|
||||
@Field(() => Temperature, { nullable: true })
|
||||
unit?: Temperature;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
warning?: number;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
critical?: number;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
hot?: number;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
max?: number;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class MemoryLayout extends Node {
|
||||
@Field(() => GraphQLBigInt)
|
||||
size!: number;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
bank?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
type?: string;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
clockSpeed?: number;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
formFactor?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
manufacturer?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
partNum?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
serialNum?: string;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
voltageConfigured?: number;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
voltageMin?: number;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
voltageMax?: number;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoMemory extends Node {
|
||||
@Field(() => GraphQLBigInt)
|
||||
max!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt)
|
||||
total!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt)
|
||||
free!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt)
|
||||
used!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt)
|
||||
active!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt)
|
||||
available!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt)
|
||||
buffcache!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt)
|
||||
swaptotal!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt)
|
||||
swapused!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt)
|
||||
swapfree!: number;
|
||||
|
||||
@Field(() => [MemoryLayout])
|
||||
layout!: MemoryLayout[];
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class Os extends Node {
|
||||
@Field(() => String, { nullable: true })
|
||||
platform?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
distro?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
release?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
codename?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
kernel?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
arch?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
hostname?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
codepage?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
logofile?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
serial?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
build?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
uptime?: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class System extends Node {
|
||||
@Field(() => String, { nullable: true })
|
||||
manufacturer?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
model?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
version?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
serial?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
uuid?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
sku?: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class Versions extends Node {
|
||||
@Field(() => String, { nullable: true })
|
||||
kernel?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
openssl?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
systemOpenssl?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
systemOpensslLib?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
node?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
v8?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
npm?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
yarn?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
pm2?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
gulp?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
grunt?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
git?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
tsc?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
mysql?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
redis?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
mongodb?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
apache?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
nginx?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
php?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
docker?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
postfix?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
postgresql?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
perl?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
python?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
gcc?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
unraid?: string;
|
||||
}
|
||||
import { InfoCpu } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js';
|
||||
import { InfoDevices } from '@app/unraid-api/graph/resolvers/info/devices/devices.model.js';
|
||||
import { InfoDisplay } from '@app/unraid-api/graph/resolvers/info/display/display.model.js';
|
||||
import { InfoMemory } from '@app/unraid-api/graph/resolvers/info/memory/memory.model.js';
|
||||
import { InfoOs } from '@app/unraid-api/graph/resolvers/info/os/os.model.js';
|
||||
import { InfoBaseboard, InfoSystem } from '@app/unraid-api/graph/resolvers/info/system/system.model.js';
|
||||
import { InfoVersions } from '@app/unraid-api/graph/resolvers/info/versions/versions.model.js';
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class Info extends Node {
|
||||
@Field(() => InfoApps, { description: 'Count of docker containers' })
|
||||
apps!: InfoApps;
|
||||
|
||||
@Field(() => Baseboard)
|
||||
baseboard!: Baseboard;
|
||||
|
||||
@Field(() => InfoCpu)
|
||||
cpu!: InfoCpu;
|
||||
|
||||
@Field(() => Devices)
|
||||
devices!: Devices;
|
||||
|
||||
@Field(() => Display)
|
||||
display!: Display;
|
||||
|
||||
@Field(() => PrefixedID, { description: 'Machine ID', nullable: true })
|
||||
machineId?: string;
|
||||
|
||||
@Field(() => InfoMemory)
|
||||
memory!: InfoMemory;
|
||||
|
||||
@Field(() => Os)
|
||||
os!: Os;
|
||||
|
||||
@Field(() => System)
|
||||
system!: System;
|
||||
|
||||
@Field(() => GraphQLISODateTime)
|
||||
@Field(() => GraphQLISODateTime, { description: 'Current server time' })
|
||||
time!: Date;
|
||||
|
||||
@Field(() => Versions)
|
||||
versions!: Versions;
|
||||
@Field(() => InfoBaseboard, { description: 'Motherboard information' })
|
||||
baseboard!: InfoBaseboard;
|
||||
|
||||
@Field(() => InfoCpu, { description: 'CPU information' })
|
||||
cpu!: InfoCpu;
|
||||
|
||||
@Field(() => InfoDevices, { description: 'Device information' })
|
||||
devices!: InfoDevices;
|
||||
|
||||
@Field(() => InfoDisplay, { description: 'Display configuration' })
|
||||
display!: InfoDisplay;
|
||||
|
||||
@Field(() => ID, { nullable: true, description: 'Machine ID' })
|
||||
machineId?: string;
|
||||
|
||||
@Field(() => InfoMemory, { description: 'Memory information' })
|
||||
memory!: InfoMemory;
|
||||
|
||||
@Field(() => InfoOs, { description: 'Operating system information' })
|
||||
os!: InfoOs;
|
||||
|
||||
@Field(() => InfoSystem, { description: 'System information' })
|
||||
system!: InfoSystem;
|
||||
|
||||
@Field(() => InfoVersions, { description: 'Software versions' })
|
||||
versions!: InfoVersions;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user