mirror of
https://github.com/unraid/api.git
synced 2026-01-02 14:40:01 -06:00
Compare commits
124 Commits
v4.7.0
...
4.9.2-buil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fb34f3c91 | ||
|
|
d159d4b0dd | ||
|
|
4168f43e3e | ||
|
|
20de3ec8d6 | ||
|
|
39b8f453da | ||
|
|
6bf3f77638 | ||
|
|
a79d049865 | ||
|
|
5b6bcb6043 | ||
|
|
6ee3cae962 | ||
|
|
f3671c3e07 | ||
|
|
862b54de8c | ||
|
|
9624ca5c39 | ||
|
|
22638811a9 | ||
|
|
5edfd823b8 | ||
|
|
0379845618 | ||
|
|
5a3e7a91eb | ||
|
|
aefcc762ca | ||
|
|
07b59b0970 | ||
|
|
91883e2b47 | ||
|
|
e57ecd551f | ||
|
|
45bb49bcd6 | ||
|
|
06578fcdf5 | ||
|
|
e791cc680d | ||
|
|
5ce5d19db0 | ||
|
|
c0ccdfa030 | ||
|
|
d613bfa041 | ||
|
|
453a5b2c95 | ||
|
|
8a82a3a1b7 | ||
|
|
9b85e009b8 | ||
|
|
a87d455bac | ||
|
|
412b32996d | ||
|
|
ba75a409a4 | ||
|
|
345e83bfb0 | ||
|
|
7be8bc84d3 | ||
|
|
4d97e1465b | ||
|
|
94420e4d45 | ||
|
|
711cc9ac92 | ||
|
|
0ec0de982f | ||
|
|
a2807864ac | ||
|
|
f88400eea8 | ||
|
|
0d443de20e | ||
|
|
27b33f0f95 | ||
|
|
13bd9bb567 | ||
|
|
f542c8e0bd | ||
|
|
038c582aed | ||
|
|
b9a1b9b087 | ||
|
|
d08fc94afb | ||
|
|
7c6f02a5cb | ||
|
|
ffb2ac51a4 | ||
|
|
719f460016 | ||
|
|
3cfe9fe9ee | ||
|
|
e539d7f603 | ||
|
|
a8566e9e5a | ||
|
|
f73e5e0058 | ||
|
|
64ccea2a81 | ||
|
|
86bea56272 | ||
|
|
af6e56de60 | ||
|
|
c4c99843c7 | ||
|
|
3122bdb953 | ||
|
|
22fe91cd56 | ||
|
|
b7c2407840 | ||
|
|
17b7428779 | ||
|
|
a7ef06ea25 | ||
|
|
5ba4479663 | ||
|
|
7bc583b186 | ||
|
|
b8035c207a | ||
|
|
1b279bbab3 | ||
|
|
68df344a4b | ||
|
|
53ca41404f | ||
|
|
9492c2ae6a | ||
|
|
3eb92dc9ea | ||
|
|
f12d231e63 | ||
|
|
75ad8381bd | ||
|
|
9901039a38 | ||
|
|
70c790ff89 | ||
|
|
0788756b91 | ||
|
|
d9ab58eb83 | ||
|
|
642a220c3a | ||
|
|
b6c4ee6eb4 | ||
|
|
8c8a5276b4 | ||
|
|
3dcbfbe489 | ||
|
|
184b76de1c | ||
|
|
c132f28281 | ||
|
|
5517e7506b | ||
|
|
d37dc3bce2 | ||
|
|
83076bb940 | ||
|
|
7b005cbbf6 | ||
|
|
b625227913 | ||
|
|
e54d27aede | ||
|
|
574d572d65 | ||
|
|
5355115af2 | ||
|
|
881f1e0960 | ||
|
|
7067e9e3dd | ||
|
|
f71943b62b | ||
|
|
9ce2fee380 | ||
|
|
7d88b3393c | ||
|
|
f6ec2839b5 | ||
|
|
02de89d130 | ||
|
|
eb080e5d22 | ||
|
|
edc0d1578b | ||
|
|
fcd6fbcdd4 | ||
|
|
fc68ea03d1 | ||
|
|
26ecf779e6 | ||
|
|
00da27d04f | ||
|
|
106ea09399 | ||
|
|
cb43f95233 | ||
|
|
d26c6b0760 | ||
|
|
2ade7eb527 | ||
|
|
e580f646a5 | ||
|
|
2704c0464c | ||
|
|
74a70b5557 | ||
|
|
469333acd4 | ||
|
|
8f70326d0f | ||
|
|
4f63b4cf3b | ||
|
|
a5f48da322 | ||
|
|
291ee475fb | ||
|
|
948580917d | ||
|
|
39e83b2aa1 | ||
|
|
831050f4e8 | ||
|
|
7ace6d3076 | ||
|
|
1ff3d7285e | ||
|
|
874a507e60 | ||
|
|
32c6fe6295 | ||
|
|
586653ccc1 |
18
.claude/settings.local.json
Normal file
18
.claude/settings.local.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(rg:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(pnpm codegen:*)",
|
||||
"Bash(pnpm dev:*)",
|
||||
"Bash(pnpm build:*)",
|
||||
"Bash(pnpm test:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(pnpm type-check:*)",
|
||||
"Bash(pnpm lint:*)",
|
||||
"Bash(pnpm --filter ./api lint)",
|
||||
"Bash(mv:*)"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": false
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description:
|
||||
globs: api/*
|
||||
globs: api/**/*,api/*
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
@@ -8,4 +8,6 @@ alwaysApply: false
|
||||
* always run scripts from api/package.json unless requested
|
||||
* prefer adding new files to the nest repo located at api/src/unraid-api/ instead of the legacy code
|
||||
* Test suite is VITEST, do not use jest
|
||||
* Prefer to not mock simple dependencies
|
||||
pnpm --filter ./api test
|
||||
* Prefer to not mock simple dependencies
|
||||
|
||||
|
||||
8
.cursor/rules/default.mdc
Normal file
8
.cursor/rules/default.mdc
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
Never add comments unless they are needed for clarity of function
|
||||
|
||||
Be CONCISE, keep replies shorter than a paragraph if at all passible.
|
||||
6
.cursor/rules/no-comments.mdc
Normal file
6
.cursor/rules/no-comments.mdc
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
Never add comments for obvious things, and avoid commenting when starting and ending code blocks
|
||||
9
.cursor/rules/web-graphql.mdc
Normal file
9
.cursor/rules/web-graphql.mdc
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
description:
|
||||
globs: web/**/*
|
||||
alwaysApply: false
|
||||
---
|
||||
* Always run `pnpm codegen` for GraphQL code generation in the web directory
|
||||
* GraphQL queries must be placed in `.query.ts` files
|
||||
* GraphQL mutations must be placed in `.mutation.ts` files
|
||||
* All GraphQL under `web/` and follow this naming convention
|
||||
@@ -8,7 +8,7 @@ alwaysApply: false
|
||||
- This is a Nuxt.js app but we are testing with vitest outside of the Nuxt environment
|
||||
- Nuxt is currently set to auto import so some vue files may need compute or ref imported
|
||||
- Use pnpm when running termical commands and stay within the web directory.
|
||||
- The directory for tests is located under `web/test` when running test just run `pnpm test`
|
||||
- The directory for tests is located under `web/__test__` when running test just run `pnpm test`
|
||||
|
||||
### Setup
|
||||
- Use `mount` from Vue Test Utils for component testing
|
||||
@@ -18,6 +18,8 @@ alwaysApply: false
|
||||
```typescript
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { useSomeStore } from '@/stores/myStore'
|
||||
import YourComponent from '~/components/YourComponent.vue';
|
||||
|
||||
// Mock dependencies
|
||||
@@ -33,14 +35,25 @@ describe('YourComponent', () => {
|
||||
it('renders correctly', () => {
|
||||
const wrapper = mount(YourComponent, {
|
||||
global: {
|
||||
plugins: [createTestingPinia()],
|
||||
stubs: {
|
||||
// Stub child components when needed
|
||||
ChildComponent: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const store = useSomeStore() // uses the testing pinia!
|
||||
// state can be directly manipulated
|
||||
store.name = 'my new name'
|
||||
|
||||
// actions are stubbed by default, meaning they don't execute their code by default.
|
||||
// See below to customize this behavior.
|
||||
store.someAction()
|
||||
|
||||
expect(store.someAction).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Assertions
|
||||
// Assertions on components
|
||||
expect(wrapper.text()).toContain('Expected content');
|
||||
});
|
||||
});
|
||||
@@ -51,6 +64,7 @@ describe('YourComponent', () => {
|
||||
- Verify that the expected elements are rendered
|
||||
- Test component interactions (clicks, inputs, etc.)
|
||||
- Check for expected prop handling and event emissions
|
||||
- Use `createTestingPinia()` for mocking stores in components
|
||||
|
||||
### Finding Elements
|
||||
- Use semantic queries like `find('button')` or `find('[data-test="id"]')` but prefer not to use data test ID's
|
||||
@@ -84,6 +98,8 @@ describe('YourComponent', () => {
|
||||
## Store Testing with Pinia
|
||||
|
||||
### Basic Setup
|
||||
- When testing Store files use `createPinia` and `setActivePinia`
|
||||
|
||||
```typescript
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
49
.github/codeql/README.md
vendored
Normal file
49
.github/codeql/README.md
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
# CodeQL Security Analysis for Unraid API
|
||||
|
||||
This directory contains custom CodeQL queries and configurations for security analysis of the Unraid API codebase.
|
||||
|
||||
## Overview
|
||||
|
||||
The analysis is configured to run:
|
||||
- On all pushes to the main branch
|
||||
- On all pull requests
|
||||
- Weekly via scheduled runs
|
||||
|
||||
## Custom Queries
|
||||
|
||||
The following custom queries are implemented:
|
||||
|
||||
1. **API Authorization Bypass Detection**
|
||||
Identifies API handlers that may not properly check authorization before performing operations.
|
||||
|
||||
2. **GraphQL Injection Detection**
|
||||
Detects potential injection vulnerabilities in GraphQL queries and operations.
|
||||
|
||||
3. **Hardcoded Secrets Detection**
|
||||
Finds potential hardcoded secrets or credentials in the codebase.
|
||||
|
||||
4. **Insecure Cryptographic Implementations**
|
||||
Identifies usage of weak cryptographic algorithms or insecure random number generation.
|
||||
|
||||
5. **Path Traversal Vulnerability Detection**
|
||||
Detects potential path traversal vulnerabilities in file system operations.
|
||||
|
||||
## Configuration
|
||||
|
||||
The CodeQL analysis is configured in:
|
||||
- `.github/workflows/codeql-analysis.yml` - Workflow configuration
|
||||
- `.github/codeql/codeql-config.yml` - CodeQL engine configuration
|
||||
|
||||
## Running Locally
|
||||
|
||||
To run these queries locally:
|
||||
|
||||
1. Install the CodeQL CLI: https://github.com/github/codeql-cli-binaries/releases
|
||||
2. Create a CodeQL database:
|
||||
```
|
||||
codeql database create <db-name> --language=javascript --source-root=.
|
||||
```
|
||||
3. Run a query:
|
||||
```
|
||||
codeql query run .github/codeql/custom-queries/javascript/api-auth-bypass.ql --database=<db-name>
|
||||
```
|
||||
16
.github/codeql/codeql-config.yml
vendored
Normal file
16
.github/codeql/codeql-config.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
name: "Unraid API CodeQL Configuration"
|
||||
|
||||
disable-default-queries: false
|
||||
|
||||
queries:
|
||||
- name: Extended Security Queries
|
||||
uses: security-extended
|
||||
- name: Custom Unraid API Queries
|
||||
uses: ./.github/codeql/custom-queries
|
||||
|
||||
query-filters:
|
||||
- exclude:
|
||||
problem.severity:
|
||||
- warning
|
||||
- recommendation
|
||||
tags contain: security
|
||||
45
.github/codeql/custom-queries/javascript/api-auth-bypass.ql
vendored
Normal file
45
.github/codeql/custom-queries/javascript/api-auth-bypass.ql
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @name Potential API Authorization Bypass
|
||||
* @description Functions that process API requests without verifying authorization may lead to security vulnerabilities.
|
||||
* @kind problem
|
||||
* @problem.severity error
|
||||
* @precision medium
|
||||
* @id js/api-auth-bypass
|
||||
* @tags security
|
||||
* external/cwe/cwe-285
|
||||
*/
|
||||
|
||||
import javascript
|
||||
|
||||
/**
|
||||
* Identifies functions that appear to handle API requests
|
||||
*/
|
||||
predicate isApiHandler(Function f) {
|
||||
exists(f.getAParameter()) and
|
||||
(
|
||||
f.getName().regexpMatch("(?i).*(api|handler|controller|resolver|endpoint).*") or
|
||||
exists(CallExpr call |
|
||||
call.getCalleeName().regexpMatch("(?i).*(get|post|put|delete|patch).*") and
|
||||
call.getArgument(1) = f
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies expressions that appear to perform authorization checks
|
||||
*/
|
||||
predicate isAuthCheck(DataFlow::Node node) {
|
||||
exists(CallExpr call |
|
||||
call.getCalleeName().regexpMatch("(?i).*(authorize|authenticate|isAuth|checkAuth|verifyAuth|hasPermission|isAdmin|canAccess).*") and
|
||||
call.flow().getASuccessor*() = node
|
||||
)
|
||||
}
|
||||
|
||||
from Function apiHandler
|
||||
where
|
||||
isApiHandler(apiHandler) and
|
||||
not exists(DataFlow::Node authCheck |
|
||||
isAuthCheck(authCheck) and
|
||||
authCheck.getEnclosingExpr().getEnclosingFunction() = apiHandler
|
||||
)
|
||||
select apiHandler, "API handler function may not perform proper authorization checks."
|
||||
77
.github/codeql/custom-queries/javascript/graphql-injection.ql
vendored
Normal file
77
.github/codeql/custom-queries/javascript/graphql-injection.ql
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @name Potential GraphQL Injection
|
||||
* @description User-controlled input used directly in GraphQL queries may lead to injection vulnerabilities.
|
||||
* @kind path-problem
|
||||
* @problem.severity error
|
||||
* @precision high
|
||||
* @id js/graphql-injection
|
||||
* @tags security
|
||||
* external/cwe/cwe-943
|
||||
*/
|
||||
|
||||
import javascript
|
||||
import DataFlow::PathGraph
|
||||
|
||||
class GraphQLQueryExecution extends DataFlow::CallNode {
|
||||
GraphQLQueryExecution() {
|
||||
exists(string name |
|
||||
name = this.getCalleeName() and
|
||||
(
|
||||
name = "execute" or
|
||||
name = "executeQuery" or
|
||||
name = "query" or
|
||||
name.regexpMatch("(?i).*graphql.*query.*")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
DataFlow::Node getQuery() {
|
||||
result = this.getArgument(0)
|
||||
}
|
||||
}
|
||||
|
||||
class UserControlledInput extends DataFlow::Node {
|
||||
UserControlledInput() {
|
||||
exists(DataFlow::ParameterNode param |
|
||||
param.getName().regexpMatch("(?i).*(query|request|input|args|variables|params).*") and
|
||||
this = param
|
||||
)
|
||||
or
|
||||
exists(DataFlow::PropRead prop |
|
||||
prop.getPropertyName().regexpMatch("(?i).*(query|request|input|args|variables|params).*") and
|
||||
this = prop
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds if `node` is a string concatenation.
|
||||
*/
|
||||
predicate isStringConcatenation(DataFlow::Node node) {
|
||||
exists(BinaryExpr concat |
|
||||
concat.getOperator() = "+" and
|
||||
concat.flow() = node
|
||||
)
|
||||
}
|
||||
|
||||
class GraphQLInjectionConfig extends TaintTracking::Configuration {
|
||||
GraphQLInjectionConfig() { this = "GraphQLInjectionConfig" }
|
||||
|
||||
override predicate isSource(DataFlow::Node source) {
|
||||
source instanceof UserControlledInput
|
||||
}
|
||||
|
||||
override predicate isSink(DataFlow::Node sink) {
|
||||
exists(GraphQLQueryExecution exec | sink = exec.getQuery())
|
||||
}
|
||||
|
||||
override predicate isAdditionalTaintStep(DataFlow::Node pred, DataFlow::Node succ) {
|
||||
// Add any GraphQL-specific taint steps if needed
|
||||
isStringConcatenation(succ) and
|
||||
succ.(DataFlow::BinaryExprNode).getAnOperand() = pred
|
||||
}
|
||||
}
|
||||
|
||||
from GraphQLInjectionConfig config, DataFlow::PathNode source, DataFlow::PathNode sink
|
||||
where config.hasFlowPath(source, sink)
|
||||
select sink.getNode(), source, sink, "GraphQL query may contain user-controlled input from $@.", source.getNode(), "user input"
|
||||
53
.github/codeql/custom-queries/javascript/hardcoded-secrets.ql
vendored
Normal file
53
.github/codeql/custom-queries/javascript/hardcoded-secrets.ql
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @name Hardcoded Secrets
|
||||
* @description Hardcoded secrets or credentials in source code can lead to security vulnerabilities.
|
||||
* @kind problem
|
||||
* @problem.severity error
|
||||
* @precision medium
|
||||
* @id js/hardcoded-secrets
|
||||
* @tags security
|
||||
* external/cwe/cwe-798
|
||||
*/
|
||||
|
||||
import javascript
|
||||
|
||||
/**
|
||||
* Identifies variable declarations or assignments that may contain secrets
|
||||
*/
|
||||
predicate isSensitiveAssignment(DataFlow::Node node) {
|
||||
exists(DataFlow::PropWrite propWrite |
|
||||
propWrite.getPropertyName().regexpMatch("(?i).*(secret|key|password|token|credential|auth).*") and
|
||||
propWrite.getRhs() = node
|
||||
)
|
||||
or
|
||||
exists(VariableDeclarator decl |
|
||||
decl.getName().regexpMatch("(?i).*(secret|key|password|token|credential|auth).*") and
|
||||
decl.getInit().flow() = node
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies literals that look like secrets
|
||||
*/
|
||||
predicate isSecretLiteral(StringLiteral literal) {
|
||||
// Match alphanumeric strings of moderate length that may be secrets
|
||||
literal.getValue().regexpMatch("[A-Za-z0-9_\\-]{8,}") and
|
||||
|
||||
not (
|
||||
// Skip likely non-sensitive literals
|
||||
literal.getValue().regexpMatch("(?i)^(true|false|null|undefined|localhost|development|production|staging)$") or
|
||||
// Skip URLs without credentials
|
||||
literal.getValue().regexpMatch("^https?://[^:@/]+")
|
||||
)
|
||||
}
|
||||
|
||||
from DataFlow::Node source
|
||||
where
|
||||
isSensitiveAssignment(source) and
|
||||
(
|
||||
exists(StringLiteral literal |
|
||||
literal.flow() = source and
|
||||
isSecretLiteral(literal)
|
||||
)
|
||||
)
|
||||
select source, "This assignment may contain a hardcoded secret or credential."
|
||||
90
.github/codeql/custom-queries/javascript/insecure-crypto.ql
vendored
Normal file
90
.github/codeql/custom-queries/javascript/insecure-crypto.ql
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @name Insecure Cryptographic Implementation
|
||||
* @description Usage of weak cryptographic algorithms or improper implementations can lead to security vulnerabilities.
|
||||
* @kind problem
|
||||
* @problem.severity error
|
||||
* @precision high
|
||||
* @id js/insecure-crypto
|
||||
* @tags security
|
||||
* external/cwe/cwe-327
|
||||
*/
|
||||
|
||||
import javascript
|
||||
|
||||
/**
|
||||
* Identifies calls to crypto functions with insecure algorithms
|
||||
*/
|
||||
predicate isInsecureCryptoCall(CallExpr call) {
|
||||
// Node.js crypto module uses
|
||||
exists(string methodName |
|
||||
methodName = call.getCalleeName() and
|
||||
(
|
||||
// Detect MD5 usage
|
||||
methodName.regexpMatch("(?i).*md5.*") or
|
||||
methodName.regexpMatch("(?i).*sha1.*") or
|
||||
|
||||
// Insecure crypto constructors
|
||||
(
|
||||
methodName = "createHash" or
|
||||
methodName = "createCipheriv" or
|
||||
methodName = "createDecipher"
|
||||
) and
|
||||
(
|
||||
exists(StringLiteral algo |
|
||||
algo = call.getArgument(0) and
|
||||
(
|
||||
algo.getValue().regexpMatch("(?i).*(md5|md4|md2|sha1|des|rc4|blowfish).*") or
|
||||
algo.getValue().regexpMatch("(?i).*(ecb).*") // ECB mode
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
or
|
||||
// Browser crypto API uses
|
||||
exists(MethodCallExpr mce, string propertyName |
|
||||
propertyName = mce.getMethodName() and
|
||||
(
|
||||
propertyName = "subtle" and
|
||||
exists(MethodCallExpr subtleCall |
|
||||
subtleCall.getReceiver() = mce and
|
||||
subtleCall.getMethodName() = "encrypt" and
|
||||
exists(ObjectExpr obj |
|
||||
obj = subtleCall.getArgument(0) and
|
||||
exists(Property p |
|
||||
p = obj.getAProperty() and
|
||||
p.getName() = "name" and
|
||||
exists(StringLiteral algo |
|
||||
algo = p.getInit() and
|
||||
algo.getValue().regexpMatch("(?i).*(rc4|des|aes-cbc).*")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies usage of Math.random() for security-sensitive operations
|
||||
*/
|
||||
predicate isInsecureRandomCall(CallExpr call) {
|
||||
exists(PropertyAccess prop |
|
||||
prop.getPropertyName() = "random" and
|
||||
prop.getBase().toString() = "Math" and
|
||||
call.getCallee() = prop
|
||||
)
|
||||
}
|
||||
|
||||
from Expr insecureExpr, string message
|
||||
where
|
||||
(
|
||||
insecureExpr instanceof CallExpr and
|
||||
isInsecureCryptoCall(insecureExpr) and
|
||||
message = "Using potentially insecure cryptographic algorithm or mode."
|
||||
) or (
|
||||
insecureExpr instanceof CallExpr and
|
||||
isInsecureRandomCall(insecureExpr) and
|
||||
message = "Using Math.random() for security-sensitive operation. Consider using crypto.getRandomValues() instead."
|
||||
)
|
||||
select insecureExpr, message
|
||||
130
.github/codeql/custom-queries/javascript/path-traversal.ql
vendored
Normal file
130
.github/codeql/custom-queries/javascript/path-traversal.ql
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @name Path Traversal Vulnerability
|
||||
* @description User-controlled inputs used in file operations may allow for path traversal attacks.
|
||||
* @kind path-problem
|
||||
* @problem.severity error
|
||||
* @precision high
|
||||
* @id js/path-traversal
|
||||
* @tags security
|
||||
* external/cwe/cwe-22
|
||||
*/
|
||||
|
||||
import javascript
|
||||
import DataFlow::PathGraph
|
||||
|
||||
/**
|
||||
* Identifies sources of user-controlled input
|
||||
*/
|
||||
class UserInput extends DataFlow::Node {
|
||||
UserInput() {
|
||||
// HTTP request parameters
|
||||
exists(DataFlow::ParameterNode param |
|
||||
param.getName().regexpMatch("(?i).*(req|request|param|query|body|user|input).*") and
|
||||
this = param
|
||||
)
|
||||
or
|
||||
// Access to common request properties
|
||||
exists(DataFlow::PropRead prop |
|
||||
(
|
||||
prop.getPropertyName() = "query" or
|
||||
prop.getPropertyName() = "body" or
|
||||
prop.getPropertyName() = "params" or
|
||||
prop.getPropertyName() = "files"
|
||||
) and
|
||||
this = prop
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies fs module imports
|
||||
*/
|
||||
class FileSystemAccess extends DataFlow::CallNode {
|
||||
FileSystemAccess() {
|
||||
// Node.js fs module functions
|
||||
exists(string name |
|
||||
name = this.getCalleeName() and
|
||||
(
|
||||
name = "readFile" or
|
||||
name = "readFileSync" or
|
||||
name = "writeFile" or
|
||||
name = "writeFileSync" or
|
||||
name = "appendFile" or
|
||||
name = "appendFileSync" or
|
||||
name = "createReadStream" or
|
||||
name = "createWriteStream" or
|
||||
name = "openSync" or
|
||||
name = "open"
|
||||
)
|
||||
)
|
||||
or
|
||||
// File system operations via require('fs')
|
||||
exists(DataFlow::SourceNode fsModule, string methodName |
|
||||
(fsModule.getAPropertyRead("promises") or fsModule).flowsTo(this.getReceiver()) and
|
||||
methodName = this.getMethodName() and
|
||||
(
|
||||
methodName = "readFile" or
|
||||
methodName = "writeFile" or
|
||||
methodName = "appendFile" or
|
||||
methodName = "readdir" or
|
||||
methodName = "stat"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
DataFlow::Node getPathArgument() {
|
||||
result = this.getArgument(0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies sanitization of file paths
|
||||
*/
|
||||
predicate isPathSanitized(DataFlow::Node node) {
|
||||
// Check for path normalization or validation
|
||||
exists(DataFlow::CallNode call |
|
||||
(
|
||||
call.getCalleeName() = "resolve" or
|
||||
call.getCalleeName() = "normalize" or
|
||||
call.getCalleeName() = "isAbsolute" or
|
||||
call.getCalleeName() = "relative" or
|
||||
call.getCalleeName().regexpMatch("(?i).*(sanitize|validate|check).*path.*")
|
||||
) and
|
||||
call.flowsTo(node)
|
||||
)
|
||||
or
|
||||
// Check for path traversal mitigation patterns
|
||||
exists(DataFlow::CallNode call |
|
||||
call.getCalleeName() = "replace" and
|
||||
exists(StringLiteral regex |
|
||||
regex = call.getArgument(0).(DataFlow::RegExpCreationNode).getSource().getAChildExpr() and
|
||||
regex.getValue().regexpMatch("(\\.\\./|\\.\\.\\\\)")
|
||||
) and
|
||||
call.flowsTo(node)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for tracking flow from user input to file system operations
|
||||
*/
|
||||
class PathTraversalConfig extends TaintTracking::Configuration {
|
||||
PathTraversalConfig() { this = "PathTraversalConfig" }
|
||||
|
||||
override predicate isSource(DataFlow::Node source) {
|
||||
source instanceof UserInput
|
||||
}
|
||||
|
||||
override predicate isSink(DataFlow::Node sink) {
|
||||
exists(FileSystemAccess fileAccess |
|
||||
sink = fileAccess.getPathArgument()
|
||||
)
|
||||
}
|
||||
|
||||
override predicate isSanitizer(DataFlow::Node node) {
|
||||
isPathSanitized(node)
|
||||
}
|
||||
}
|
||||
|
||||
from PathTraversalConfig config, DataFlow::PathNode source, DataFlow::PathNode sink
|
||||
where config.hasFlowPath(source, sink)
|
||||
select sink.getNode(), source, sink, "File system operation depends on a user-controlled value $@.", source.getNode(), "user input"
|
||||
19
.github/workflows/build-plugin.yml
vendored
19
.github/workflows/build-plugin.yml
vendored
@@ -23,6 +23,10 @@ on:
|
||||
type: string
|
||||
required: true
|
||||
description: "Base URL for the plugin builds"
|
||||
BUILD_NUMBER:
|
||||
type: string
|
||||
required: true
|
||||
description: "Build number for the plugin builds"
|
||||
secrets:
|
||||
CF_ACCESS_KEY_ID:
|
||||
required: true
|
||||
@@ -100,11 +104,6 @@ jobs:
|
||||
with:
|
||||
name: unraid-api
|
||||
path: ${{ github.workspace }}/plugin/api/
|
||||
- name: Download PNPM Store
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: packed-node-modules
|
||||
path: ${{ github.workspace }}/plugin/
|
||||
- name: Extract Unraid API
|
||||
run: |
|
||||
mkdir -p ${{ github.workspace }}/plugin/source/dynamix.unraid.net/usr/local/unraid-api
|
||||
@@ -113,9 +112,8 @@ jobs:
|
||||
id: build-plugin
|
||||
run: |
|
||||
cd ${{ github.workspace }}/plugin
|
||||
ls -al
|
||||
pnpm run build:txz
|
||||
pnpm run build:plugin --tag="${{ inputs.TAG }}" --base-url="${{ inputs.BASE_URL }}"
|
||||
pnpm run build:txz --tag="${{ inputs.TAG }}" --base-url="${{ inputs.BASE_URL }}" --api-version="${{ steps.vars.outputs.API_VERSION }}" --build-number="${{ inputs.BUILD_NUMBER }}"
|
||||
pnpm run build:plugin --tag="${{ inputs.TAG }}" --base-url="${{ inputs.BASE_URL }}" --api-version="${{ steps.vars.outputs.API_VERSION }}" --build-number="${{ inputs.BUILD_NUMBER }}"
|
||||
|
||||
- name: Ensure Plugin Files Exist
|
||||
run: |
|
||||
@@ -130,11 +128,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f ./deploy/*.tar.xz ]; then
|
||||
echo "Error: .tar.xz file not found in plugin/deploy/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload to GHA
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
40
.github/workflows/codeql-analysis.yml
vendored
Normal file
40
.github/workflows/codeql-analysis.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: "CodeQL Security Analysis"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
- cron: '0 0 * * 0' # Run weekly on Sundays
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript', 'typescript' ]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: ./.github/codeql/codeql-config.yml
|
||||
queries: +security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
76
.github/workflows/deploy-storybook.yml
vendored
Normal file
76
.github/workflows/deploy-storybook.yml
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
name: Deploy Storybook to Cloudflare Workers
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'unraid-ui/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'unraid-ui/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy Storybook
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
|
||||
with:
|
||||
packages: bash procps python3 libvirt-dev jq zstd git build-essential libvirt-daemon-system
|
||||
version: 1.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Storybook
|
||||
run: |
|
||||
cd unraid-ui
|
||||
pnpm build-storybook
|
||||
|
||||
- name: Deploy to Cloudflare Workers (Staging)
|
||||
id: deploy_staging
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_DEPLOY_TOKEN }}
|
||||
command: deploy --env staging
|
||||
workingDirectory: unraid-ui
|
||||
|
||||
- name: Deploy to Cloudflare Workers (Production)
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_DEPLOY_TOKEN }}
|
||||
command: deploy
|
||||
workingDirectory: unraid-ui
|
||||
|
||||
- name: Comment PR with deployment URL
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `🚀 Storybook has been deployed to staging: ${{ steps.deploy_staging.outputs['deployment-url'] }}`
|
||||
})
|
||||
77
.github/workflows/main.yml
vendored
77
.github/workflows/main.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
|
||||
with:
|
||||
packages: bash procps python3 libvirt-dev jq zstd git build-essential libvirt-daemon-system
|
||||
version: 1.0
|
||||
@@ -72,6 +72,12 @@ jobs:
|
||||
- name: PNPM Install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Type Check
|
||||
run: pnpm run type-check
|
||||
|
||||
- name: Setup libvirt
|
||||
run: |
|
||||
# Create required groups (if they don't already exist)
|
||||
@@ -94,7 +100,7 @@ jobs:
|
||||
auth_unix_rw = "none"
|
||||
EOF
|
||||
|
||||
# Add the current user to libvirt and kvm groups (note: this change won’t apply to the current session)
|
||||
# Add the current user to libvirt and kvm groups (note: this change won't apply to the current session)
|
||||
sudo usermod -aG libvirt,kvm $USER
|
||||
|
||||
sudo mkdir -p /var/run/libvirt
|
||||
@@ -111,15 +117,47 @@ jobs:
|
||||
# Verify libvirt is running using sudo to bypass group membership delays
|
||||
sudo virsh list --all || true
|
||||
|
||||
- name: Lint
|
||||
run: pnpm run lint
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Test
|
||||
run: pnpm run coverage
|
||||
- name: Run Tests Concurrently
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Run all tests in parallel with labeled output
|
||||
echo "🚀 Starting API coverage tests..."
|
||||
pnpm run coverage > api-test.log 2>&1 &
|
||||
API_PID=$!
|
||||
|
||||
echo "🚀 Starting Connect plugin tests..."
|
||||
(cd ../packages/unraid-api-plugin-connect && pnpm test) > connect-test.log 2>&1 &
|
||||
CONNECT_PID=$!
|
||||
|
||||
echo "🚀 Starting Shared package tests..."
|
||||
(cd ../packages/unraid-shared && pnpm test) > shared-test.log 2>&1 &
|
||||
SHARED_PID=$!
|
||||
|
||||
# Wait for all processes and capture exit codes
|
||||
wait $API_PID && echo "✅ API tests completed" || { echo "❌ API tests failed"; API_EXIT=1; }
|
||||
wait $CONNECT_PID && echo "✅ Connect tests completed" || { echo "❌ Connect tests failed"; CONNECT_EXIT=1; }
|
||||
wait $SHARED_PID && echo "✅ Shared tests completed" || { echo "❌ Shared tests failed"; SHARED_EXIT=1; }
|
||||
|
||||
# Display all outputs
|
||||
echo "📋 API Test Results:" && cat api-test.log
|
||||
echo "📋 Connect Plugin Test Results:" && cat connect-test.log
|
||||
echo "📋 Shared Package Test Results:" && cat shared-test.log
|
||||
|
||||
# Exit with error if any test failed
|
||||
if [[ ${API_EXIT:-0} -eq 1 || ${CONNECT_EXIT:-0} -eq 1 || ${SHARED_EXIT:-0} -eq 1 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-api:
|
||||
name: Build API
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
build_number: ${{ steps.buildnumber.outputs.build_number }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: api
|
||||
@@ -152,7 +190,7 @@ jobs:
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
|
||||
with:
|
||||
packages: bash procps python3 libvirt-dev jq zstd git build-essential
|
||||
version: 1.0
|
||||
@@ -162,12 +200,6 @@ jobs:
|
||||
cd ${{ github.workspace }}
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Type Check
|
||||
run: pnpm run type-check
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
@@ -179,11 +211,19 @@ jobs:
|
||||
PACKAGE_LOCK_VERSION=$(jq -r '.version' package.json)
|
||||
API_VERSION=$([[ -n "$IS_TAGGED" ]] && echo "$PACKAGE_LOCK_VERSION" || echo "${PACKAGE_LOCK_VERSION}+${GIT_SHA}")
|
||||
export API_VERSION
|
||||
echo "API_VERSION=${API_VERSION}" >> $GITHUB_ENV
|
||||
echo "PACKAGE_LOCK_VERSION=${PACKAGE_LOCK_VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate build number
|
||||
id: buildnumber
|
||||
uses: onyxmueller/build-tag-number@v1
|
||||
with:
|
||||
token: ${{secrets.github_token}}
|
||||
prefix: ${{steps.vars.outputs.PACKAGE_LOCK_VERSION}}
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
pnpm run build:release
|
||||
|
||||
tar -czf deploy/unraid-api.tgz -C deploy/pack/ .
|
||||
|
||||
- name: Upload tgz to Github artifacts
|
||||
@@ -191,11 +231,6 @@ jobs:
|
||||
with:
|
||||
name: unraid-api
|
||||
path: ${{ github.workspace }}/api/deploy/unraid-api.tgz
|
||||
- name: Upload Node Modules to Github artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: packed-node-modules
|
||||
path: ${{ github.workspace }}/api/deploy/packed-node-modules.tar.xz
|
||||
|
||||
build-unraid-ui-webcomponents:
|
||||
name: Build Unraid UI Library (Webcomponent Version)
|
||||
@@ -232,7 +267,7 @@ jobs:
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
|
||||
with:
|
||||
packages: bash procps python3 libvirt-dev jq zstd git build-essential
|
||||
version: 1.0
|
||||
@@ -340,6 +375,7 @@ jobs:
|
||||
TAG: ${{ github.event.pull_request.number && format('PR{0}', github.event.pull_request.number) || '' }}
|
||||
BUCKET_PATH: ${{ github.event.pull_request.number && format('unraid-api/tag/PR{0}', github.event.pull_request.number) || 'unraid-api' }}
|
||||
BASE_URL: "https://preview.dl.unraid.net/unraid-api"
|
||||
BUILD_NUMBER: ${{ needs.build-api.outputs.build_number }}
|
||||
secrets:
|
||||
CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
|
||||
CF_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}
|
||||
@@ -362,6 +398,7 @@ jobs:
|
||||
TAG: ""
|
||||
BUCKET_PATH: unraid-api
|
||||
BASE_URL: "https://stable.dl.unraid.net/unraid-api"
|
||||
BUILD_NUMBER: ${{ needs.build-api.outputs.build_number }}
|
||||
secrets:
|
||||
CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
|
||||
CF_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}
|
||||
|
||||
49
.github/workflows/push-staging-pr-on-close.yml
vendored
49
.github/workflows/push-staging-pr-on-close.yml
vendored
@@ -4,43 +4,68 @@ on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: "PR number to test with"
|
||||
required: true
|
||||
type: string
|
||||
pr_merged:
|
||||
description: "Simulate merged PR"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
push-staging:
|
||||
if: github.event.pull_request.merged == true
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.merged == true) || (github.event_name == 'workflow_dispatch' && inputs.pr_merged == true)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- name: Set Timezone
|
||||
uses: szenius/set-timezone@v2.0
|
||||
with:
|
||||
timezoneLinux: "America/Los_Angeles"
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: refs/pull/${{ github.event.pull_request.base.ref }}/merge
|
||||
|
||||
- name: Set PR number
|
||||
id: pr_number
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "pull_request" ]; then
|
||||
echo "pr_number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "pr_number=${{ inputs.pr_number }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: dawidd6/action-download-artifact@v11
|
||||
with:
|
||||
name: connect-files
|
||||
name_is_regexp: true
|
||||
name: unraid-plugin-.*
|
||||
path: connect-files
|
||||
pr: ${{ steps.pr_number.outputs.pr_number }}
|
||||
workflow_conclusion: success
|
||||
workflow_search: true
|
||||
search_artifacts: true
|
||||
|
||||
- name: Update Downloaded Staging Plugin to New Date
|
||||
run: |
|
||||
if [ ! -f "connect-files/plugins/dynamix.unraid.net.pr.plg" ]; then
|
||||
echo "ERROR: dynamix.unraid.net.pr.plg not found"
|
||||
# Find the .plg file in the downloaded artifact
|
||||
plgfile=$(find connect-files -name "*.plg" -type f | head -1)
|
||||
if [ ! -f "$plgfile" ]; then
|
||||
echo "ERROR: .plg file not found in connect-files/"
|
||||
ls -la connect-files/
|
||||
exit 1
|
||||
fi
|
||||
|
||||
plgfile="connect-files/plugins/dynamix.unraid.net.pr.plg"
|
||||
echo "Found plugin file: $plgfile"
|
||||
version=$(date +"%Y.%m.%d.%H%M")
|
||||
sed -i -E "s#(<!ENTITY version \").*(\">)#\1${version}\2#g" "${plgfile}" || exit 1
|
||||
|
||||
# Change the plugin url to point to staging
|
||||
url="https://preview.dl.unraid.net/unraid-api/dynamix.unraid.net.plg"
|
||||
sed -i -E "s#(<!ENTITY pluginURL \").*(\">)#\1${url}\2#g" "${plgfile}" || exit 1
|
||||
sed -i -E "s#(<!ENTITY plugin_url \").*?(\">)#\1${url}\2#g" "${plgfile}" || exit 1
|
||||
cat "${plgfile}"
|
||||
mkdir -p pr-release
|
||||
mv "${plgfile}" pr-release/dynamix.unraid.net.plg
|
||||
@@ -54,4 +79,4 @@ jobs:
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}
|
||||
AWS_REGION: "auto"
|
||||
SOURCE_DIR: pr-release
|
||||
DEST_DIR: unraid-api/pr/${{ github.event.pull_request.number }}
|
||||
DEST_DIR: unraid-api/tag/PR${{ steps.pr_number.outputs.pr_number }}
|
||||
|
||||
6
.github/workflows/release-production.yml
vendored
6
.github/workflows/release-production.yml
vendored
@@ -30,9 +30,11 @@ jobs:
|
||||
prerelease: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.x'
|
||||
node-version: '22.17.0'
|
||||
- run: |
|
||||
echo '${{ steps.release-info.outputs.body }}' >> release-notes.txt
|
||||
cat << 'EOF' > release-notes.txt
|
||||
${{ steps.release-info.outputs.body }}
|
||||
EOF
|
||||
- run: npm install html-escaper@2 xml2js
|
||||
- name: Update Plugin Changelog
|
||||
uses: actions/github-script@v7
|
||||
|
||||
4
.github/workflows/test-libvirt.yml
vendored
4
.github/workflows/test-libvirt.yml
vendored
@@ -28,10 +28,10 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
python-version: "3.13.5"
|
||||
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
|
||||
with:
|
||||
packages: libvirt-dev
|
||||
version: 1.0
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -108,4 +108,4 @@ web/scripts/.sync-webgui-repo-*
|
||||
plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/data/activation-data.php
|
||||
|
||||
# Config file that changes between versions
|
||||
api/dev/Unraid.net/myservers.cfg
|
||||
api/dev/Unraid.net/myservers.cfg
|
||||
|
||||
12
.husky/_/pre-commit
Executable file
12
.husky/_/pre-commit
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
|
||||
if [ "$SKIP_SIMPLE_GIT_HOOKS" = "1" ]; then
|
||||
echo "[INFO] SKIP_SIMPLE_GIT_HOOKS is set to 1, skipping hook."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -f "$SIMPLE_GIT_HOOKS_RC" ]; then
|
||||
. "$SIMPLE_GIT_HOOKS_RC"
|
||||
fi
|
||||
|
||||
pnpm lint-staged
|
||||
1
.rclone-version
Normal file
1
.rclone-version
Normal file
@@ -0,0 +1 @@
|
||||
1.69.1
|
||||
@@ -1 +1 @@
|
||||
{".":"4.7.0"}
|
||||
{".":"4.9.2"}
|
||||
|
||||
27
.vscode/settings.json
vendored
27
.vscode/settings.json
vendored
@@ -1,15 +1,14 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"*.page": "php"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "never",
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"i18n-ally.localesPaths": [
|
||||
"locales"
|
||||
],
|
||||
"i18n-ally.keystyle": "flat",
|
||||
"eslint.experimental.useFlatConfig": true
|
||||
}
|
||||
|
||||
"files.associations": {
|
||||
"*.page": "php"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "never",
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"i18n-ally.localesPaths": ["locales"],
|
||||
"i18n-ally.keystyle": "flat",
|
||||
"eslint.experimental.useFlatConfig": true,
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"javascript.preferences.importModuleSpecifier": "non-relative"
|
||||
}
|
||||
|
||||
137
CLAUDE.md
Normal file
137
CLAUDE.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is the Unraid API monorepo containing multiple packages that provide API functionality for Unraid servers. It uses pnpm workspaces with the following structure:
|
||||
|
||||
- `/api` - Core NestJS API server with GraphQL
|
||||
- `/web` - Nuxt.js frontend application
|
||||
- `/unraid-ui` - Vue 3 component library
|
||||
- `/plugin` - Unraid plugin package (.plg)
|
||||
- `/packages` - Shared packages and API plugins
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
pnpm install # Install all dependencies
|
||||
pnpm dev # Run all dev servers concurrently
|
||||
pnpm build # Build all packages
|
||||
pnpm build:watch # Watch mode with local plugin build
|
||||
```
|
||||
|
||||
### Testing & Code Quality
|
||||
|
||||
```bash
|
||||
pnpm test # Run all tests
|
||||
pnpm lint # Run linting
|
||||
pnpm lint:fix # Fix linting issues
|
||||
pnpm type-check # TypeScript type checking
|
||||
```
|
||||
|
||||
### API Development
|
||||
|
||||
```bash
|
||||
cd api && pnpm dev # Run API server (http://localhost:3001)
|
||||
cd api && pnpm test:watch # Run tests in watch mode
|
||||
cd api && pnpm codegen # Generate GraphQL types
|
||||
```
|
||||
|
||||
### Deployment
|
||||
|
||||
```bash
|
||||
pnpm unraid:deploy <SERVER_IP> # Deploy all to Unraid server
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### API Structure (NestJS)
|
||||
|
||||
- Modules: `auth`, `config`, `plugins`, `emhttp`, `monitoring`
|
||||
- GraphQL API with Apollo Server at `/graphql`
|
||||
- Redux store for state management in `src/store/`
|
||||
- Plugin system for extending functionality
|
||||
- Entry points: `src/index.ts` (server), `src/cli.ts` (CLI)
|
||||
|
||||
### Key Patterns
|
||||
|
||||
- TypeScript imports use `.js` extensions (ESM compatibility)
|
||||
- NestJS dependency injection with decorators
|
||||
- GraphQL schema-first approach with code generation
|
||||
- API plugins follow specific structure (see `api/docs/developer/api-plugins.md`)
|
||||
|
||||
### Authentication
|
||||
|
||||
- API key authentication via headers
|
||||
- Cookie-based session management
|
||||
- Keys stored in `/boot/config/plugins/unraid-api/`
|
||||
|
||||
### Development Workflow
|
||||
|
||||
1. Work Intent required before starting development
|
||||
2. Fork from `main` branch
|
||||
3. Reference Work Intent in PR
|
||||
4. No direct pushes to main
|
||||
|
||||
### Debug Mode
|
||||
|
||||
```bash
|
||||
LOG_LEVEL=debug unraid-api start --debug
|
||||
```
|
||||
|
||||
Enables GraphQL playground at `http://tower.local/graphql`
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
### General Rules
|
||||
|
||||
- Never add comments unless they are needed for clarity of function
|
||||
- Never add comments for obvious things, and avoid commenting when starting and ending code blocks
|
||||
- Be CONCISE, keep replies shorter than a paragraph if at all possible
|
||||
|
||||
### API Development Rules (`api/**/*`)
|
||||
|
||||
- Use pnpm ONLY for package management
|
||||
- Always run scripts from api/package.json unless requested
|
||||
- Prefer adding new files to the NestJS repo located at `api/src/unraid-api/` instead of the legacy code
|
||||
- Test suite is VITEST, do not use jest
|
||||
- Run tests with: `pnpm --filter ./api test`
|
||||
- Prefer to not mock simple dependencies
|
||||
|
||||
### Web Development Rules (`web/**/*`)
|
||||
|
||||
- Always run `pnpm codegen` for GraphQL code generation in the web directory
|
||||
- GraphQL queries must be placed in `.query.ts` files
|
||||
- GraphQL mutations must be placed in `.mutation.ts` files
|
||||
- All GraphQL under `web/` must follow this naming convention
|
||||
|
||||
### Testing Guidelines
|
||||
|
||||
#### Vue Component Testing
|
||||
|
||||
- This is a Nuxt.js app but we are testing with vitest outside of the Nuxt environment
|
||||
- Nuxt is currently set to auto import so some vue files may need compute or ref imported
|
||||
- Use pnpm when running terminal commands and stay within the web directory
|
||||
- Tests are located under `web/__test__`, run with `pnpm test`
|
||||
- Use `mount` from Vue Test Utils for component testing
|
||||
- Stub complex child components that aren't the focus of the test
|
||||
- Mock external dependencies and services
|
||||
- Test component behavior and output, not implementation details
|
||||
- Use `createTestingPinia()` for mocking stores in components
|
||||
- Find elements with semantic queries like `find('button')` rather than data-test IDs
|
||||
- Use `await nextTick()` for DOM updates
|
||||
- Always await async operations before making assertions
|
||||
|
||||
#### Store Testing with Pinia
|
||||
|
||||
- Use `createPinia()` and `setActivePinia` when testing Store files
|
||||
- Only use `createTestingPinia` if you specifically need its testing features
|
||||
- Let stores initialize with their natural default state
|
||||
- Don't mock the store being tested
|
||||
- Ensure Vue reactivity imports are added to store files (computed, ref, watchEffect)
|
||||
- Place all mock declarations at the top level
|
||||
- Use factory functions for module mocks to avoid hoisting issues
|
||||
- Clear mocks between tests to ensure isolation
|
||||
@@ -11,6 +11,10 @@ PATHS_KEYFILE_BASE=./dev/Unraid.net # Keyfile location
|
||||
PATHS_MACHINE_ID=./dev/data/machine-id
|
||||
PATHS_PARITY_CHECKS=./dev/states/parity-checks.log
|
||||
PATHS_CONFIG_MODULES=./dev/configs
|
||||
PATHS_ACTIVATION_BASE=./dev/activation
|
||||
PATHS_PASSWD=./dev/passwd
|
||||
PATHS_RCLONE_SOCKET=./dev/rclone-socket
|
||||
PATHS_LOG_BASE=./dev/log # Where we store logs
|
||||
ENVIRONMENT="development"
|
||||
NODE_ENV="development"
|
||||
PORT="3001"
|
||||
|
||||
@@ -11,5 +11,7 @@ PATHS_KEYFILE_BASE=./dev/Unraid.net # Keyfile location
|
||||
PATHS_MACHINE_ID=./dev/data/machine-id
|
||||
PATHS_PARITY_CHECKS=./dev/states/parity-checks.log
|
||||
PATHS_CONFIG_MODULES=./dev/configs
|
||||
PATHS_ACTIVATION_BASE=./dev/activation
|
||||
PATHS_PASSWD=./dev/passwd
|
||||
PORT=5000
|
||||
NODE_ENV="test"
|
||||
|
||||
4
api/.vscode/settings.json
vendored
4
api/.vscode/settings.json
vendored
@@ -3,5 +3,7 @@
|
||||
"eslint.options": {
|
||||
"flags": ["unstable_ts_config"],
|
||||
"overrideConfigFile": ".eslintrc.ts"
|
||||
}
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"javascript.preferences.importModuleSpecifier": "non-relative"
|
||||
}
|
||||
|
||||
116
api/CHANGELOG.md
116
api/CHANGELOG.md
@@ -1,5 +1,121 @@
|
||||
# Changelog
|
||||
|
||||
## [4.9.2](https://github.com/unraid/api/compare/v4.9.1...v4.9.2) (2025-07-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* invalid configs no longer crash API ([#1491](https://github.com/unraid/api/issues/1491)) ([6bf3f77](https://github.com/unraid/api/commit/6bf3f776380edeff5133517e6aca223556e30144))
|
||||
* invalid state for unraid plugin ([#1492](https://github.com/unraid/api/issues/1492)) ([39b8f45](https://github.com/unraid/api/commit/39b8f453da23793ef51f8e7f7196370aada8c5aa))
|
||||
* release note escaping ([5b6bcb6](https://github.com/unraid/api/commit/5b6bcb6043a5269bff4dc28714d787a5a3f07e22))
|
||||
|
||||
## [4.9.1](https://github.com/unraid/api/compare/v4.9.0...v4.9.1) (2025-07-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **HeaderOsVersion:** adjust top margin for header component ([#1485](https://github.com/unraid/api/issues/1485)) ([862b54d](https://github.com/unraid/api/commit/862b54de8cd793606f1d29e76c19d4a0e1ae172f))
|
||||
* sign out doesn't work ([#1486](https://github.com/unraid/api/issues/1486)) ([f3671c3](https://github.com/unraid/api/commit/f3671c3e0750b79be1f19655a07a0e9932289b3f))
|
||||
|
||||
## [4.9.0](https://github.com/unraid/api/compare/v4.8.0...v4.9.0) (2025-07-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add graphql resource for API plugins ([#1420](https://github.com/unraid/api/issues/1420)) ([642a220](https://github.com/unraid/api/commit/642a220c3a796829505d8449dc774968c9d5c222))
|
||||
* add management page for API keys ([#1408](https://github.com/unraid/api/issues/1408)) ([0788756](https://github.com/unraid/api/commit/0788756b918a8e99be51f34bf6f96bbe5b67395a))
|
||||
* add rclone ([#1362](https://github.com/unraid/api/issues/1362)) ([5517e75](https://github.com/unraid/api/commit/5517e7506b05c7bef5012bb9f8d2103e91061997))
|
||||
* API key management ([#1407](https://github.com/unraid/api/issues/1407)) ([d37dc3b](https://github.com/unraid/api/commit/d37dc3bce28bad1c893ae7eff96ca5ffd9177648))
|
||||
* api plugin management via CLI ([#1416](https://github.com/unraid/api/issues/1416)) ([3dcbfbe](https://github.com/unraid/api/commit/3dcbfbe48973b8047f0c6c560068808d86ac6970))
|
||||
* build out docker components ([#1427](https://github.com/unraid/api/issues/1427)) ([711cc9a](https://github.com/unraid/api/commit/711cc9ac926958bcf2996455b023ad265b041530))
|
||||
* docker and info resolver issues ([#1423](https://github.com/unraid/api/issues/1423)) ([9901039](https://github.com/unraid/api/commit/9901039a3863de06b520e23cb2573b610716c673))
|
||||
* fix shading in UPC to be less severe ([#1438](https://github.com/unraid/api/issues/1438)) ([b7c2407](https://github.com/unraid/api/commit/b7c240784052276fc60e064bd7d64dd6e801ae90))
|
||||
* info resolver cleanup ([#1425](https://github.com/unraid/api/issues/1425)) ([1b279bb](https://github.com/unraid/api/commit/1b279bbab3a51e7d032e7e3c9898feac8bfdbafa))
|
||||
* initial codeql setup ([#1390](https://github.com/unraid/api/issues/1390)) ([2ade7eb](https://github.com/unraid/api/commit/2ade7eb52792ef481aaf711dc07029629ea107d9))
|
||||
* initialize claude code in codebse ([#1418](https://github.com/unraid/api/issues/1418)) ([b6c4ee6](https://github.com/unraid/api/commit/b6c4ee6eb4b9ebb6d6e59a341e1f51b253578752))
|
||||
* move api key fetching to use api key service ([#1439](https://github.com/unraid/api/issues/1439)) ([86bea56](https://github.com/unraid/api/commit/86bea5627270a2a18c5b7db36dd59061ab61e753))
|
||||
* move to cron v4 ([#1428](https://github.com/unraid/api/issues/1428)) ([b8035c2](https://github.com/unraid/api/commit/b8035c207a6e387c7af3346593a872664f6c867b))
|
||||
* move to iframe for changelog ([#1388](https://github.com/unraid/api/issues/1388)) ([fcd6fbc](https://github.com/unraid/api/commit/fcd6fbcdd48e7f224b3bd8799a668d9e01967f0c))
|
||||
* native slackware package ([#1381](https://github.com/unraid/api/issues/1381)) ([4f63b4c](https://github.com/unraid/api/commit/4f63b4cf3bb9391785f07a38defe54ec39071caa))
|
||||
* send active unraid theme to docs ([#1400](https://github.com/unraid/api/issues/1400)) ([f71943b](https://github.com/unraid/api/commit/f71943b62b30119e17766e56534962630f52a591))
|
||||
* slightly better watch mode ([#1398](https://github.com/unraid/api/issues/1398)) ([881f1e0](https://github.com/unraid/api/commit/881f1e09607d1e4a8606f8d048636ba09d8fcac1))
|
||||
* upgrade nuxt-custom-elements ([#1461](https://github.com/unraid/api/issues/1461)) ([345e83b](https://github.com/unraid/api/commit/345e83bfb0904381d784fc77b3dcd3ad7e53d898))
|
||||
* use bigint instead of long ([#1403](https://github.com/unraid/api/issues/1403)) ([574d572](https://github.com/unraid/api/commit/574d572d6567c652057b29776694e86267316ca7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* activation indicator removed ([5edfd82](https://github.com/unraid/api/commit/5edfd823b862cfc1f864565021f12334fe9317c6))
|
||||
* alignment of settings on ManagementAccess settings page ([#1421](https://github.com/unraid/api/issues/1421)) ([70c790f](https://github.com/unraid/api/commit/70c790ff89075a785d7f0623bbf3c34a3806bbdc))
|
||||
* allow rclone to fail to initialize ([#1453](https://github.com/unraid/api/issues/1453)) ([7c6f02a](https://github.com/unraid/api/commit/7c6f02a5cb474fb285db294ec6f80d1c2c57e142))
|
||||
* always download 7.1 versioned files for patching ([edc0d15](https://github.com/unraid/api/commit/edc0d1578b89c3b3e56e637de07137e069656fa8))
|
||||
* api `pnpm type-check` ([#1442](https://github.com/unraid/api/issues/1442)) ([3122bdb](https://github.com/unraid/api/commit/3122bdb953eec58469fd9cf6f468e75621781040))
|
||||
* **api:** connect config `email` validation ([#1454](https://github.com/unraid/api/issues/1454)) ([b9a1b9b](https://github.com/unraid/api/commit/b9a1b9b08746b6d4cb2128d029a3dab7cdd47677))
|
||||
* backport unraid/webgui[#2269](https://github.com/unraid/api/issues/2269) rc.nginx update ([#1436](https://github.com/unraid/api/issues/1436)) ([a7ef06e](https://github.com/unraid/api/commit/a7ef06ea252545cef084e21cea741a8ec866e7cc))
|
||||
* bigint ([e54d27a](https://github.com/unraid/api/commit/e54d27aede1b1e784971468777c5e65cde66f2ac))
|
||||
* config migration from `myservers.cfg` ([#1440](https://github.com/unraid/api/issues/1440)) ([c4c9984](https://github.com/unraid/api/commit/c4c99843c7104414120bffc5dd5ed78ab6c8ba02))
|
||||
* **connect:** fatal race-condition in websocket disposal ([#1462](https://github.com/unraid/api/issues/1462)) ([0ec0de9](https://github.com/unraid/api/commit/0ec0de982f017b61a145c7a4176718b484834f41))
|
||||
* **connect:** mothership connection ([#1464](https://github.com/unraid/api/issues/1464)) ([7be8bc8](https://github.com/unraid/api/commit/7be8bc84d3831f9cea7ff62d0964612ad366a976))
|
||||
* console hidden ([9b85e00](https://github.com/unraid/api/commit/9b85e009b833706294a841a54498e45a8e0204ed))
|
||||
* debounce is too long ([#1426](https://github.com/unraid/api/issues/1426)) ([f12d231](https://github.com/unraid/api/commit/f12d231e6376d0f253cee67b7ed690c432c63ec5))
|
||||
* delete legacy connect keys and ensure description ([22fe91c](https://github.com/unraid/api/commit/22fe91cd561e88aa24e8f8cfa5a6143e7644e4e0))
|
||||
* **deps:** pin dependencies ([#1465](https://github.com/unraid/api/issues/1465)) ([ba75a40](https://github.com/unraid/api/commit/ba75a409a4d3e820308b78fd5a5380021d3757b0))
|
||||
* **deps:** pin dependencies ([#1470](https://github.com/unraid/api/issues/1470)) ([412b329](https://github.com/unraid/api/commit/412b32996d9c8352c25309cc0d549a57468d0fb5))
|
||||
* **deps:** storybook v9 ([#1476](https://github.com/unraid/api/issues/1476)) ([45bb49b](https://github.com/unraid/api/commit/45bb49bcd60a9753be492203111e489fd37c1a5f))
|
||||
* **deps:** update all non-major dependencies ([#1366](https://github.com/unraid/api/issues/1366)) ([291ee47](https://github.com/unraid/api/commit/291ee475fb9ef44f6da7b76a9eb11b7dd29a5d13))
|
||||
* **deps:** update all non-major dependencies ([#1379](https://github.com/unraid/api/issues/1379)) ([8f70326](https://github.com/unraid/api/commit/8f70326d0fe3e4c3bcd3e8e4e6566766f1c05eb7))
|
||||
* **deps:** update all non-major dependencies ([#1389](https://github.com/unraid/api/issues/1389)) ([cb43f95](https://github.com/unraid/api/commit/cb43f95233590888a8e20a130e62cadc176c6793))
|
||||
* **deps:** update all non-major dependencies ([#1399](https://github.com/unraid/api/issues/1399)) ([68df344](https://github.com/unraid/api/commit/68df344a4b412227cffa96867f086177b251f028))
|
||||
* **deps:** update dependency @types/diff to v8 ([#1393](https://github.com/unraid/api/issues/1393)) ([00da27d](https://github.com/unraid/api/commit/00da27d04f2ee2ca8b8b9cdcc6ea3c490c02a3a4))
|
||||
* **deps:** update dependency cache-manager to v7 ([#1413](https://github.com/unraid/api/issues/1413)) ([9492c2a](https://github.com/unraid/api/commit/9492c2ae6a0086d14e73d280c55746206b73a7b0))
|
||||
* **deps:** update dependency commander to v14 ([#1394](https://github.com/unraid/api/issues/1394)) ([106ea09](https://github.com/unraid/api/commit/106ea093996f2d0c71c1511bc009ecc9a6be91ec))
|
||||
* **deps:** update dependency diff to v8 ([#1386](https://github.com/unraid/api/issues/1386)) ([e580f64](https://github.com/unraid/api/commit/e580f646a52b8bda605132cf44ec58137e08dd42))
|
||||
* **deps:** update dependency dotenv to v17 ([#1474](https://github.com/unraid/api/issues/1474)) ([d613bfa](https://github.com/unraid/api/commit/d613bfa0410e7ef8451fc8ea20e57a7db67f7994))
|
||||
* **deps:** update dependency lucide-vue-next to ^0.509.0 ([#1383](https://github.com/unraid/api/issues/1383)) ([469333a](https://github.com/unraid/api/commit/469333acd4a0cbeecc9e9cbadb2884289d83aee3))
|
||||
* **deps:** update dependency marked to v16 ([#1444](https://github.com/unraid/api/issues/1444)) ([453a5b2](https://github.com/unraid/api/commit/453a5b2c9591f755ce07548a9996d7a6cf0925c4))
|
||||
* **deps:** update dependency shadcn-vue to v2 ([#1302](https://github.com/unraid/api/issues/1302)) ([26ecf77](https://github.com/unraid/api/commit/26ecf779e675d0bc533d61e045325ab062effcbf))
|
||||
* **deps:** update dependency vue-sonner to v2 ([#1401](https://github.com/unraid/api/issues/1401)) ([53ca414](https://github.com/unraid/api/commit/53ca41404f13c057c340dcf9010af72c3365e499))
|
||||
* disable file changes on Unraid 7.2 ([#1382](https://github.com/unraid/api/issues/1382)) ([02de89d](https://github.com/unraid/api/commit/02de89d1309f67e4b6d4f8de5f66815ee4d2464c))
|
||||
* do not start API with doinst.sh ([7d88b33](https://github.com/unraid/api/commit/7d88b3393cbd8ab1e93a86dfa1b7b74cc97255cc))
|
||||
* do not uninstall fully on 7.2 ([#1484](https://github.com/unraid/api/issues/1484)) ([2263881](https://github.com/unraid/api/commit/22638811a9fdb524420b1347ac49cfaa51bbecb5))
|
||||
* drop console with terser ([a87d455](https://github.com/unraid/api/commit/a87d455bace04aab9d7fa0e63cb61d26ef9b3b72))
|
||||
* error logs from `cloud` query when connect is not installed ([#1450](https://github.com/unraid/api/issues/1450)) ([719f460](https://github.com/unraid/api/commit/719f460016d769255582742d7d71ca97d132022b))
|
||||
* flash backup integration with Unraid Connect config ([#1448](https://github.com/unraid/api/issues/1448)) ([038c582](https://github.com/unraid/api/commit/038c582aed5f5efaea3583372778b9baa318e1ea))
|
||||
* header padding regression ([#1477](https://github.com/unraid/api/issues/1477)) ([e791cc6](https://github.com/unraid/api/commit/e791cc680de9c40378043348ddca70902da6d250))
|
||||
* incorrect state merging in redux store ([#1437](https://github.com/unraid/api/issues/1437)) ([17b7428](https://github.com/unraid/api/commit/17b74287796e6feb75466033e279dc3bcf57f1e6))
|
||||
* lanip copy button not present ([#1459](https://github.com/unraid/api/issues/1459)) ([a280786](https://github.com/unraid/api/commit/a2807864acef742e454d87bb093ee91806e527e5))
|
||||
* move to bigint scalar ([b625227](https://github.com/unraid/api/commit/b625227913e80e4731a13b54b525ec7385918c51))
|
||||
* node_modules dir removed on plugin update ([#1406](https://github.com/unraid/api/issues/1406)) ([7b005cb](https://github.com/unraid/api/commit/7b005cbbf682a1336641f5fc85022e9d651569d0))
|
||||
* omit Connect actions in UPC when plugin is not installed ([#1417](https://github.com/unraid/api/issues/1417)) ([8c8a527](https://github.com/unraid/api/commit/8c8a5276b49833c08bca133e374e1e66273b41aa))
|
||||
* parsing of `ssoEnabled` in state.php ([#1455](https://github.com/unraid/api/issues/1455)) ([f542c8e](https://github.com/unraid/api/commit/f542c8e0bd9596d9d3abf75b58b97d95fb033215))
|
||||
* pin ranges ([#1460](https://github.com/unraid/api/issues/1460)) ([f88400e](https://github.com/unraid/api/commit/f88400eea820ac80c867fdb63cd503ed91493146))
|
||||
* pr plugin promotion workflow ([#1456](https://github.com/unraid/api/issues/1456)) ([13bd9bb](https://github.com/unraid/api/commit/13bd9bb5670bb96b158068114d62572d88c7cae9))
|
||||
* proper fallback if missing paths config modules ([7067e9e](https://github.com/unraid/api/commit/7067e9e3dd3966309013b52c90090cc82de4e4fb))
|
||||
* rc.unraid-api now cleans up older dependencies ([#1404](https://github.com/unraid/api/issues/1404)) ([83076bb](https://github.com/unraid/api/commit/83076bb94088095de8b1a332a50bbef91421f0c1))
|
||||
* remote access lifecycle during boot & shutdown ([#1422](https://github.com/unraid/api/issues/1422)) ([7bc583b](https://github.com/unraid/api/commit/7bc583b18621c8140232772ca36c6d9b8d8a9cd7))
|
||||
* sign out correctly on error ([#1452](https://github.com/unraid/api/issues/1452)) ([d08fc94](https://github.com/unraid/api/commit/d08fc94afb94e386907da44402ee5a24cfb3d00a))
|
||||
* simplify usb listing ([#1402](https://github.com/unraid/api/issues/1402)) ([5355115](https://github.com/unraid/api/commit/5355115af2f4122af9afa3f63ed8f830b33cbf5c))
|
||||
* theme issues when sent from graph ([#1424](https://github.com/unraid/api/issues/1424)) ([75ad838](https://github.com/unraid/api/commit/75ad8381bd4f4045ab1d3aa84e08ecddfba27617))
|
||||
* **ui:** notifications positioning regression ([#1445](https://github.com/unraid/api/issues/1445)) ([f73e5e0](https://github.com/unraid/api/commit/f73e5e0058fcc3bedebfbe7380ffcb44aea981b8))
|
||||
* use some instead of every for connect detection ([9ce2fee](https://github.com/unraid/api/commit/9ce2fee380c4db1395f5d4df7f16ae6c57d1a748))
|
||||
|
||||
|
||||
### Reverts
|
||||
|
||||
* revert package.json dependency updates from commit 711cc9a for api and packages/* ([94420e4](https://github.com/unraid/api/commit/94420e4d45735b8def3915b5789c15c1c3121f1e))
|
||||
|
||||
## [4.8.0](https://github.com/unraid/api/compare/v4.7.0...v4.8.0) (2025-05-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* move activation code logic into the API ([#1369](https://github.com/unraid/api/issues/1369)) ([39e83b2](https://github.com/unraid/api/commit/39e83b2aa156586ab4da362137194280fccefe7c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 400 error when submitting connect settings ([831050f](https://github.com/unraid/api/commit/831050f4e8c3af4cbcc123a3a609025f250f0824))
|
||||
|
||||
## [4.7.0](https://github.com/unraid/api/compare/v4.6.6...v4.7.0) (2025-04-24)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
###########################################################
|
||||
# Development/Build Image
|
||||
###########################################################
|
||||
FROM node:22-bookworm-slim AS development
|
||||
FROM node:22.17.0-bookworm-slim AS development
|
||||
|
||||
# Install build tools and dependencies
|
||||
RUN apt-get update -y && apt-get install -y \
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[api]
|
||||
version="4.6.6"
|
||||
version="4.4.1"
|
||||
extraOrigins="https://google.com,https://test.com"
|
||||
[local]
|
||||
sandbox="yes"
|
||||
|
||||
13
api/dev/activation/activation_code_12345.activationcode
Normal file
13
api/dev/activation/activation_code_12345.activationcode
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"code": "EXAMPLE_CODE_123",
|
||||
"partnerName": "MyPartner Inc.",
|
||||
"partnerUrl": "https://partner.example.com",
|
||||
"serverName": "MyAwesomeServer",
|
||||
"sysModel": "CustomBuild v1.0",
|
||||
"comment": "This is a test activation code for development.",
|
||||
"header": "#336699",
|
||||
"headermetacolor": "#FFFFFF",
|
||||
"background": "#F0F0F0",
|
||||
"showBannerGradient": "yes",
|
||||
"theme": "black"
|
||||
}
|
||||
1
api/dev/activation/applied.txt
Normal file
1
api/dev/activation/applied.txt
Normal file
@@ -0,0 +1 @@
|
||||
true
|
||||
BIN
api/dev/activation/assets/case-model.png
Normal file
BIN
api/dev/activation/assets/case-model.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
19
api/dev/activation/assets/logo.svg
Normal file
19
api/dev/activation/assets/logo.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="442" height="221">
|
||||
<defs>
|
||||
<linearGradient id="gradient_0" gradientUnits="userSpaceOnUse" x1="608.84924" y1="48.058002" x2="447.47684" y2="388.15295">
|
||||
<stop offset="0" stop-color="#ECC02F"/>
|
||||
<stop offset="1" stop-color="#B8436B"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#gradient_0)" transform="scale(0.431641 0.431641)" d="M126.543 236.139C141.269 184.983 170.747 148.08 228.938 144.823C240.378 144.182 259.66 144.749 271.333 145.215C299.585 144.391 350.558 142.667 377.842 145.685C414.099 149.696 443.185 175.429 472.192 195.251L586.561 274.337C636.114 308.874 627.234 309.151 685.21 309.042L778.304 309.082C799.091 309.099 813.482 308.867 828.82 292.529C857.893 261.561 843.003 209.317 800.506 200.17C790.505 198.018 779.334 199.535 769.11 199.523L702.658 199.488C690.005 186.062 675.199 151.817 658.182 145.215L739.199 145.198C765.636 145.196 796.164 142.886 821.565 150.344C923.889 180.389 922.324 331.136 816.611 357.807C802.524 361.361 788.425 361.034 774.035 361.031L663.497 361.009C623.773 360.859 603.599 349.313 572.35 327.596L430.421 229.848C415.731 219.804 401.419 209.118 386.451 199.488C377.579 199.501 368.42 200.01 359.582 199.488L272.561 199.497C258.582 199.485 235.352 198.06 222.607 200.981C192.741 207.825 177.956 234.361 180.015 263.294C177.545 260.392 178.63 254.678 178.838 251.164C179.877 233.569 187.409 224.968 197.345 212.22C184.786 202.853 156.933 193.749 149.447 186.645C143.454 196.583 136.881 205.628 132.955 216.732C130.766 222.921 130.678 230.967 127.506 236.625L126.543 236.139Z"/>
|
||||
<path fill="#308DAF" transform="scale(0.431641 0.431641)" d="M149.447 186.645C156.933 193.749 184.786 202.853 197.345 212.22C187.409 224.968 179.877 233.569 178.838 251.164C178.63 254.678 177.545 260.392 180.015 263.294C192.489 309.751 221.563 309.078 263.512 309.07L322.096 309.048C333.708 325.984 348.958 344.904 361.795 361.006L232.654 361.03C176.801 360.579 130.605 315.939 126.498 260.613C125.893 252.473 126.453 244.293 126.543 236.139L127.506 236.625C130.678 230.967 130.766 222.921 132.955 216.732C136.881 205.628 143.454 196.583 149.447 186.645Z"/>
|
||||
<defs>
|
||||
<linearGradient id="gradient_1" gradientUnits="userSpaceOnUse" x1="620.42566" y1="140.57172" x2="611.08759" y2="282.2207">
|
||||
<stop offset="0" stop-color="#F5A22C"/>
|
||||
<stop offset="1" stop-color="#E17543"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#gradient_1)" transform="scale(0.431641 0.431641)" d="M570.215 137.504C646.214 133.055 670.623 188.789 707.064 241.977L726.71 270.658C729.065 274.1 737.591 284.13 737.576 287.916L674.645 287.916C674.5 287.132 659.251 264.134 658.182 263.294C658.133 262.92 623.915 212.832 620.593 208.697C602.652 186.369 565.856 181.796 545.393 203.424C542.002 207.007 539.705 211.779 535.713 214.764C534.409 212.586 496.093 187.105 490.641 183.32C508.306 154.99 539.004 142.872 570.215 137.504Z"/>
|
||||
<path fill="#308DAF" transform="scale(0.431641 0.431641)" d="M286.656 221.485L350.512 221.485C354.248 227.374 358.556 232.986 362.565 238.698L379.9 263.82C397.44 289.065 410.994 321.185 447.698 317.317C464.599 315.536 476.472 305.449 486.751 292.741C494.293 298.818 530.089 320.341 533.124 324.28C532.441 328.231 526.229 334.319 522.861 336.255C521.587 339.958 509.164 348.519 505.635 350.88C463.781 378.879 411.472 377.537 373.808 343.464C365.331 335.795 359.734 326.969 353.351 317.641L336.798 293.614C320.035 269.591 302.915 245.863 286.656 221.485Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
10
api/dev/configs/api.json
Normal file
10
api/dev/configs/api.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": "4.8.0",
|
||||
"extraOrigins": [
|
||||
"https://google.com",
|
||||
"https://test.com"
|
||||
],
|
||||
"sandbox": true,
|
||||
"ssoSubIds": [],
|
||||
"plugins": ["unraid-api-plugin-connect"]
|
||||
}
|
||||
@@ -1,23 +1,16 @@
|
||||
{
|
||||
"demo": "2025-04-21T14:27:27.631Z",
|
||||
"wanaccess": "yes",
|
||||
"wanport": "8443",
|
||||
"upnpEnabled": "no",
|
||||
"apikey": "_______________________BIG_API_KEY_HERE_________________________",
|
||||
"localApiKey": "_______________________LOCAL_API_KEY_HERE_________________________",
|
||||
"email": "test@example.com",
|
||||
"username": "zspearmint",
|
||||
"avatar": "https://via.placeholder.com/200",
|
||||
"regWizTime": "1611175408732_0951-1653-3509-FBA155FA23C0",
|
||||
"wanaccess": false,
|
||||
"wanport": 0,
|
||||
"upnpEnabled": false,
|
||||
"apikey": "",
|
||||
"localApiKey": "",
|
||||
"email": "",
|
||||
"username": "",
|
||||
"avatar": "",
|
||||
"regWizTime": "",
|
||||
"accesstoken": "",
|
||||
"idtoken": "",
|
||||
"refreshtoken": "",
|
||||
"dynamicRemoteAccessType": "DISABLED",
|
||||
"ssoSubIds": "",
|
||||
"version": "4.6.6",
|
||||
"extraOrigins": [
|
||||
"https://google.com",
|
||||
"https://test.com"
|
||||
],
|
||||
"sandbox": "yes"
|
||||
"ssoSubIds": []
|
||||
}
|
||||
@@ -1,36 +1,42 @@
|
||||
[display]
|
||||
date="%c"
|
||||
time="%I:%M %p"
|
||||
number=".,"
|
||||
scale="-1"
|
||||
tabs="1"
|
||||
users="Tasks:3"
|
||||
resize="0"
|
||||
wwn="0"
|
||||
total="1"
|
||||
usage="0"
|
||||
banner="image"
|
||||
dashapps="icons"
|
||||
theme="white"
|
||||
text="1"
|
||||
unit="C"
|
||||
warning="70"
|
||||
critical="90"
|
||||
hot="45"
|
||||
max="55"
|
||||
sysinfo="/Tools/SystemProfiler"
|
||||
date=%c
|
||||
time=%I:%M %p
|
||||
number=.,
|
||||
scale=-1
|
||||
tabs=1
|
||||
users=Tasks:3
|
||||
resize=0
|
||||
wwn=0
|
||||
total=1
|
||||
usage=0
|
||||
banner=image
|
||||
dashapps=icons
|
||||
theme=black
|
||||
text=1
|
||||
unit=C
|
||||
warning=70
|
||||
critical=90
|
||||
hot=45
|
||||
max=55
|
||||
sysinfo=/Tools/SystemProfiler
|
||||
header=336699
|
||||
headermetacolor=FFFFFF
|
||||
background=F0F0F0
|
||||
showBannerGradient=yes
|
||||
|
||||
[notify]
|
||||
entity="1"
|
||||
normal="1"
|
||||
warning="1"
|
||||
alert="1"
|
||||
unraid="1"
|
||||
plugin="1"
|
||||
docker_notify="1"
|
||||
report="1"
|
||||
display="0"
|
||||
date="d-m-Y"
|
||||
time="H:i"
|
||||
position="top-right"
|
||||
path="./dev/notifications"
|
||||
system="*/1 * * * *"
|
||||
entity=1
|
||||
normal=1
|
||||
warning=1
|
||||
alert=1
|
||||
unraid=1
|
||||
plugin=1
|
||||
docker_notify=1
|
||||
report=1
|
||||
display=0
|
||||
date=d-m-Y
|
||||
time=H:i
|
||||
position=top-right
|
||||
path=./dev/notifications
|
||||
system=*/1 * * * *
|
||||
|
||||
|
||||
36
api/dev/ident.cfg
Normal file
36
api/dev/ident.cfg
Normal file
@@ -0,0 +1,36 @@
|
||||
# Generated settings:
|
||||
NAME="Unraid"
|
||||
timeZone="America/New_York"
|
||||
COMMENT="Media server"
|
||||
SECURITY="user"
|
||||
WORKGROUP="WORKGROUP"
|
||||
DOMAIN=""
|
||||
DOMAIN_SHORT=""
|
||||
hideDotFiles="no"
|
||||
enableFruit="yes"
|
||||
USE_NETBIOS="no"
|
||||
localMaster="yes"
|
||||
serverMultiChannel="no"
|
||||
USE_WSD="yes"
|
||||
WSD_OPT=""
|
||||
WSD2_OPT=""
|
||||
USE_NTP="yes"
|
||||
NTP_SERVER1="time1.google.com"
|
||||
NTP_SERVER2="time2.google.com"
|
||||
NTP_SERVER3="time3.google.com"
|
||||
NTP_SERVER4="time4.google.com"
|
||||
DOMAIN_LOGIN="Administrator"
|
||||
DOMAIN_PASSWD=""
|
||||
SYS_MODEL="Custom"
|
||||
SYS_ARRAY_SLOTS="24"
|
||||
USE_SSL="yes"
|
||||
PORT="80"
|
||||
PORTSSL="8443"
|
||||
LOCAL_TLD="local"
|
||||
BIND_MGT="no"
|
||||
USE_TELNET="no"
|
||||
PORTTELNET="23"
|
||||
USE_SSH="yes"
|
||||
PORTSSH="22"
|
||||
USE_UPNP="yes"
|
||||
START_PAGE="Main"
|
||||
@@ -1,5 +1,5 @@
|
||||
[api]
|
||||
version="4.6.6"
|
||||
version="4.4.1"
|
||||
extraOrigins="https://google.com,https://test.com"
|
||||
[local]
|
||||
sandbox="yes"
|
||||
@@ -20,5 +20,5 @@ dynamicRemoteAccessType="DISABLED"
|
||||
ssoSubIds=""
|
||||
allowedOrigins="/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, http://localhost:8080, https://localhost:4443, https://tower.local:4443, https://192.168.1.150:4443, https://tower:4443, https://192-168-1-150.thisisfourtyrandomcharacters012345678900.myunraid.net:4443, https://85-121-123-122.thisisfourtyrandomcharacters012345678900.myunraid.net:8443, https://10-252-0-1.hash.myunraid.net:4443, https://10-252-1-1.hash.myunraid.net:4443, https://10-253-3-1.hash.myunraid.net:4443, https://10-253-4-1.hash.myunraid.net:4443, https://10-253-5-1.hash.myunraid.net:4443, https://10-100-0-1.hash.myunraid.net:4443, https://10-100-0-2.hash.myunraid.net:4443, https://10-123-1-2.hash.myunraid.net:4443, https://221-123-121-112.hash.myunraid.net:4443, https://google.com, https://test.com, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000, https://studio.apollographql.com"
|
||||
[connectionStatus]
|
||||
minigraph="ERROR_RETRYING"
|
||||
minigraph="PRE_INIT"
|
||||
upnpStatus=""
|
||||
|
||||
@@ -102,6 +102,7 @@ regTm="1833409182"
|
||||
regTm2="0"
|
||||
regExp=""
|
||||
regGen="0"
|
||||
regState="ENOKEYFILE"
|
||||
sbName="/boot/config/super.dat"
|
||||
sbVersion="2.9.13"
|
||||
sbUpdated="1596079143"
|
||||
|
||||
1
api/dev/webGui/images/UN-logotype-gradient.svg
Normal file
1
api/dev/webGui/images/UN-logotype-gradient.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 222.36 39.04"><defs><linearGradient id="header-logo" x1="47.53" y1="79.1" x2="170.71" y2="-44.08" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e32929"/><stop offset="1" stop-color="#ff8d30"/></linearGradient></defs><title>unraid.net</title><path d="M146.7,29.47H135l-3,9h-6.49L138.93,0h8l13.41,38.49h-7.09L142.62,6.93l-5.83,16.88h8ZM29.69,0V25.4c0,8.91-5.77,13.64-14.9,13.64S0,34.31,0,25.4V0H6.54V25.4c0,5.17,3.19,7.92,8.25,7.92s8.36-2.75,8.36-7.92V0ZM50.86,12v26.5H44.31V0h6.11l17,26.5V0H74V38.49H67.9ZM171.29,0h6.54V38.49h-6.54Zm51.07,24.69c0,9-5.88,13.8-15.17,13.8H192.67V0H207.3c9.18,0,15.06,4.78,15.06,13.8ZM215.82,13.8c0-5.28-3.3-8.14-8.52-8.14h-8.08V32.77h8c5.33,0,8.63-2.8,8.63-8.08ZM108.31,23.92c4.34-1.6,6.93-5.28,6.93-11.55C115.24,3.68,110.18,0,102.48,0H88.84V38.49h6.55V5.66h6.87c3.8,0,6.21,1.82,6.21,6.71s-2.41,6.76-6.21,6.76H98.88l9.21,19.36h7.53Z" fill="url(#header-logo)"/></svg>
|
||||
|
After Width: | Height: | Size: 1008 B |
@@ -10,22 +10,115 @@ where the API provides dependencies for the plugin while the plugin provides fun
|
||||
### Adding a local workspace package as an API plugin
|
||||
|
||||
The challenge with local workspace plugins is that they aren't available via npm during production.
|
||||
To solve this, we vendor them inside `dist/plugins`. To prevent the build from breaking, however,
|
||||
you should mark the workspace dependency as optional. For example:
|
||||
To solve this, we vendor them during the build process. Here's the complete process:
|
||||
|
||||
#### 1. Configure the build system
|
||||
|
||||
Add your workspace package to the vendoring configuration in `api/scripts/build.ts`:
|
||||
|
||||
```typescript
|
||||
const WORKSPACE_PACKAGES_TO_VENDOR = {
|
||||
'@unraid/shared': 'packages/unraid-shared',
|
||||
'unraid-api-plugin-connect': 'packages/unraid-api-plugin-connect',
|
||||
'your-plugin-name': 'packages/your-plugin-path', // Add your plugin here
|
||||
} as const;
|
||||
```
|
||||
|
||||
#### 2. Configure Vite
|
||||
|
||||
Add your workspace package to the Vite configuration in `api/vite.config.ts`:
|
||||
|
||||
```typescript
|
||||
const workspaceDependencies = {
|
||||
'@unraid/shared': 'packages/unraid-shared',
|
||||
'unraid-api-plugin-connect': 'packages/unraid-api-plugin-connect',
|
||||
'your-plugin-name': 'packages/your-plugin-path', // Add your plugin here
|
||||
};
|
||||
```
|
||||
|
||||
This ensures the package is:
|
||||
- Excluded from Vite's optimization during development
|
||||
- Marked as external during the build process
|
||||
- Properly handled in SSR mode
|
||||
|
||||
#### 3. Configure the API package.json
|
||||
|
||||
Add your workspace package as a peer dependency in `api/package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"peerDependencies": {
|
||||
"unraid-api-plugin-connect": "workspace:*"
|
||||
"unraid-api-plugin-connect": "workspace:*",
|
||||
"your-plugin-name": "workspace:*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"unraid-api-plugin-connect": {
|
||||
"optional": true
|
||||
},
|
||||
"your-plugin-name": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
By marking the workspace dependency "optional", npm will not attempt to install it.
|
||||
Thus, even though the "workspace:*" identifier will be invalid during build-time and run-time,
|
||||
it will not cause problems.
|
||||
By marking the workspace dependency "optional", npm will not attempt to install it during development.
|
||||
The "workspace:*" identifier will be invalid during build-time and run-time, but won't cause problems
|
||||
because the package gets vendored instead.
|
||||
|
||||
#### 4. Plugin package setup
|
||||
|
||||
Your workspace plugin package should:
|
||||
|
||||
1. **Export types and main entry**: Set up proper `main`, `types`, and `exports` fields:
|
||||
```json
|
||||
{
|
||||
"name": "your-plugin-name",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist"]
|
||||
}
|
||||
```
|
||||
|
||||
2. **Use peer dependencies**: Declare shared dependencies as peer dependencies to avoid duplication:
|
||||
```json
|
||||
{
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^11.0.11",
|
||||
"@nestjs/core": "^11.0.11",
|
||||
"graphql": "^16.9.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Include build script**: Add a build script that compiles TypeScript:
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"prepare": "npm run build"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. Build process
|
||||
|
||||
During production builds:
|
||||
|
||||
1. The build script (`api/scripts/build.ts`) will automatically pack and install your workspace package as a tarball
|
||||
2. This happens after `npm install --omit=dev` in the pack directory
|
||||
3. The vendored package becomes a regular node_modules dependency in the final build
|
||||
|
||||
#### 6. Development vs Production
|
||||
|
||||
- **Development**: Vite resolves workspace packages directly from their source
|
||||
- **Production**: Packages are vendored as tarballs in `node_modules`
|
||||
|
||||
This approach ensures that workspace plugins work seamlessly in both development and production environments.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
312
api/package.json
312
api/package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/api",
|
||||
"version": "4.7.0",
|
||||
"version": "4.9.2",
|
||||
"main": "src/cli/index.ts",
|
||||
"type": "module",
|
||||
"corepack": {
|
||||
@@ -10,18 +10,19 @@
|
||||
"author": "Lime Technology, Inc. <unraid.net>",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"engines": {
|
||||
"pnpm": ">=8.0.0"
|
||||
"pnpm": "10.13.1"
|
||||
},
|
||||
"scripts": {
|
||||
"// Development": "",
|
||||
"start": "node dist/main.js",
|
||||
"dev": "vite",
|
||||
"dev:debug": "NODE_OPTIONS='--inspect-brk=9229 --enable-source-maps' vite",
|
||||
"command": "pnpm run build && clear && ./dist/cli.js",
|
||||
"command:raw": "./dist/cli.js",
|
||||
"// Build and Deploy": "",
|
||||
"build": "vite build --mode=production",
|
||||
"postbuild": "chmod +x dist/main.js && chmod +x dist/cli.js && node scripts/copy-plugins.js",
|
||||
"build:watch": "nodemon --watch src --ext ts,js,json --exec 'tsx ./scripts/build.ts'",
|
||||
"postbuild": "chmod +x dist/main.js && chmod +x dist/cli.js",
|
||||
"build:watch": "WATCH_MODE=true nodemon --watch src --ext ts,js,json --exec 'tsx ./scripts/build.ts'",
|
||||
"build:docker": "./scripts/dc.sh run --rm builder",
|
||||
"build:release": "tsx ./scripts/build.ts",
|
||||
"preunraid:deploy": "pnpm build",
|
||||
@@ -51,96 +52,97 @@
|
||||
"unraid-api": "dist/cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.11.8",
|
||||
"@apollo/server": "^4.11.2",
|
||||
"@as-integrations/fastify": "^2.1.1",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/helmet": "^13.0.1",
|
||||
"@graphql-codegen/client-preset": "^4.5.0",
|
||||
"@graphql-tools/load-files": "^7.0.0",
|
||||
"@graphql-tools/merge": "^9.0.8",
|
||||
"@graphql-tools/schema": "^10.0.7",
|
||||
"@graphql-tools/utils": "^10.5.5",
|
||||
"@jsonforms/core": "^3.5.1",
|
||||
"@nestjs/apollo": "^13.0.3",
|
||||
"@nestjs/cache-manager": "^3.0.1",
|
||||
"@nestjs/common": "^11.0.11",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.11",
|
||||
"@nestjs/graphql": "^13.0.3",
|
||||
"@nestjs/passport": "^11.0.0",
|
||||
"@nestjs/platform-fastify": "^11.0.11",
|
||||
"@nestjs/schedule": "^5.0.0",
|
||||
"@nestjs/throttler": "^6.2.1",
|
||||
"@reduxjs/toolkit": "^2.3.0",
|
||||
"@runonflux/nat-upnp": "^1.0.2",
|
||||
"@types/diff": "^7.0.1",
|
||||
"@unraid/libvirt": "^2.1.0",
|
||||
"accesscontrol": "^2.2.1",
|
||||
"bycontract": "^2.0.11",
|
||||
"bytes": "^3.1.2",
|
||||
"cache-manager": "^6.4.2",
|
||||
"cacheable-lookup": "^7.0.0",
|
||||
"camelcase-keys": "^9.1.3",
|
||||
"casbin": "^5.32.0",
|
||||
"change-case": "^5.4.4",
|
||||
"chokidar": "^4.0.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cli-table": "^0.3.11",
|
||||
"command-exists": "^1.2.9",
|
||||
"convert": "^5.8.0",
|
||||
"cookie": "^1.0.2",
|
||||
"cron": "3.5.0",
|
||||
"cross-fetch": "^4.0.0",
|
||||
"diff": "^7.0.0",
|
||||
"dockerode": "^4.0.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"execa": "^9.5.1",
|
||||
"exit-hook": "^4.0.0",
|
||||
"fastify": "^5.2.1",
|
||||
"filenamify": "^6.0.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"glob": "^11.0.1",
|
||||
"global-agent": "^3.0.0",
|
||||
"got": "^14.4.6",
|
||||
"graphql": "^16.9.0",
|
||||
"graphql-fields": "^2.0.3",
|
||||
"graphql-scalars": "^1.23.0",
|
||||
"graphql-subscriptions": "^3.0.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"graphql-type-json": "^0.3.2",
|
||||
"graphql-type-uuid": "^0.2.0",
|
||||
"graphql-ws": "^6.0.0",
|
||||
"ini": "^5.0.0",
|
||||
"ip": "^2.0.1",
|
||||
"jose": "^6.0.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"multi-ini": "^2.3.2",
|
||||
"mustache": "^4.2.0",
|
||||
"nest-authz": "^2.14.0",
|
||||
"nest-commander": "^3.15.0",
|
||||
"nestjs-pino": "^4.1.0",
|
||||
"node-cache": "^5.1.2",
|
||||
"node-window-polyfill": "^1.0.2",
|
||||
"p-retry": "^6.2.0",
|
||||
"passport-custom": "^1.1.1",
|
||||
"passport-http-header-strategy": "^1.1.0",
|
||||
"path-type": "^6.0.0",
|
||||
"pino": "^9.5.0",
|
||||
"pino-http": "^10.3.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"pm2": "^6.0.0",
|
||||
"@apollo/client": "3.13.8",
|
||||
"@apollo/server": "4.12.2",
|
||||
"@as-integrations/fastify": "2.1.1",
|
||||
"@fastify/cookie": "11.0.2",
|
||||
"@fastify/helmet": "13.0.1",
|
||||
"@graphql-codegen/client-preset": "4.8.3",
|
||||
"@graphql-tools/load-files": "7.0.1",
|
||||
"@graphql-tools/merge": "9.0.24",
|
||||
"@graphql-tools/schema": "10.0.23",
|
||||
"@graphql-tools/utils": "10.8.6",
|
||||
"@jsonforms/core": "3.6.0",
|
||||
"@nestjs/apollo": "13.1.0",
|
||||
"@nestjs/cache-manager": "3.0.1",
|
||||
"@nestjs/common": "11.1.3",
|
||||
"@nestjs/config": "4.0.2",
|
||||
"@nestjs/core": "11.1.3",
|
||||
"@nestjs/event-emitter": "3.0.1",
|
||||
"@nestjs/graphql": "13.1.0",
|
||||
"@nestjs/passport": "11.0.5",
|
||||
"@nestjs/platform-fastify": "11.1.3",
|
||||
"@nestjs/schedule": "6.0.0",
|
||||
"@nestjs/throttler": "6.4.0",
|
||||
"@reduxjs/toolkit": "2.8.2",
|
||||
"@runonflux/nat-upnp": "1.0.2",
|
||||
"@types/diff": "8.0.0",
|
||||
"@unraid/libvirt": "2.1.0",
|
||||
"@unraid/shared": "workspace:*",
|
||||
"accesscontrol": "2.2.1",
|
||||
"bycontract": "2.0.11",
|
||||
"bytes": "3.1.2",
|
||||
"cache-manager": "7.0.1",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"camelcase-keys": "9.1.3",
|
||||
"casbin": "5.38.0",
|
||||
"change-case": "5.4.4",
|
||||
"chokidar": "4.0.3",
|
||||
"class-transformer": "0.5.1",
|
||||
"class-validator": "0.14.2",
|
||||
"cli-table": "0.3.11",
|
||||
"command-exists": "1.2.9",
|
||||
"convert": "5.12.0",
|
||||
"cookie": "1.0.2",
|
||||
"cron": "4.3.1",
|
||||
"cross-fetch": "4.1.0",
|
||||
"diff": "8.0.2",
|
||||
"dockerode": "4.0.7",
|
||||
"dotenv": "17.1.0",
|
||||
"execa": "9.6.0",
|
||||
"exit-hook": "4.0.0",
|
||||
"fastify": "5.4.0",
|
||||
"filenamify": "6.0.0",
|
||||
"fs-extra": "11.3.0",
|
||||
"glob": "11.0.3",
|
||||
"global-agent": "3.0.0",
|
||||
"got": "14.4.7",
|
||||
"graphql": "16.11.0",
|
||||
"graphql-fields": "2.0.3",
|
||||
"graphql-scalars": "1.24.2",
|
||||
"graphql-subscriptions": "3.0.0",
|
||||
"graphql-tag": "2.12.6",
|
||||
"graphql-ws": "6.0.5",
|
||||
"ini": "5.0.0",
|
||||
"ip": "2.0.1",
|
||||
"jose": "6.0.11",
|
||||
"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.17.0",
|
||||
"nestjs-pino": "4.4.0",
|
||||
"node-cache": "5.1.2",
|
||||
"node-window-polyfill": "1.0.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.7.0",
|
||||
"pino-http": "10.5.0",
|
||||
"pino-pretty": "13.0.0",
|
||||
"pm2": "6.0.8",
|
||||
"reflect-metadata": "^0.1.14",
|
||||
"request": "^2.88.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"semver": "^7.6.3",
|
||||
"strftime": "^0.10.3",
|
||||
"systeminformation": "^5.25.11",
|
||||
"uuid": "^11.0.2",
|
||||
"ws": "^8.18.0",
|
||||
"zen-observable-ts": "^1.1.0",
|
||||
"zod": "^3.23.8"
|
||||
"request": "2.88.2",
|
||||
"rxjs": "7.8.2",
|
||||
"semver": "7.7.2",
|
||||
"strftime": "0.10.3",
|
||||
"systeminformation": "5.27.7",
|
||||
"uuid": "11.1.0",
|
||||
"ws": "8.18.3",
|
||||
"zen-observable-ts": "1.1.0",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"unraid-api-plugin-connect": "workspace:*"
|
||||
@@ -151,71 +153,71 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@graphql-codegen/add": "^5.0.3",
|
||||
"@graphql-codegen/cli": "^5.0.3",
|
||||
"@graphql-codegen/fragment-matcher": "^5.0.2",
|
||||
"@graphql-codegen/import-types-preset": "^3.0.0",
|
||||
"@graphql-codegen/typed-document-node": "^5.0.11",
|
||||
"@graphql-codegen/typescript": "^4.1.1",
|
||||
"@graphql-codegen/typescript-operations": "^4.3.1",
|
||||
"@graphql-codegen/typescript-resolvers": "4.5.0",
|
||||
"@graphql-typed-document-node/core": "^3.2.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.4.0",
|
||||
"@nestjs/testing": "^11.0.11",
|
||||
"@originjs/vite-plugin-commonjs": "^1.0.3",
|
||||
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||
"@swc/core": "^1.10.1",
|
||||
"@types/async-exit-hook": "^2.0.2",
|
||||
"@types/bytes": "^3.1.4",
|
||||
"@types/cli-table": "^0.3.4",
|
||||
"@types/command-exists": "^1.2.3",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/dockerode": "^3.3.31",
|
||||
"@types/graphql-fields": "^1.3.9",
|
||||
"@types/graphql-type-uuid": "^0.2.6",
|
||||
"@types/ini": "^4.1.1",
|
||||
"@types/ip": "^1.1.3",
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mustache": "^4.2.5",
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/pify": "^6.0.0",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/sendmail": "^1.4.7",
|
||||
"@types/stoppable": "^1.1.3",
|
||||
"@types/strftime": "^0.9.8",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.5.13",
|
||||
"@types/wtfnode": "^0.7.3",
|
||||
"@vitest/coverage-v8": "^3.0.5",
|
||||
"@vitest/ui": "^3.0.5",
|
||||
"@eslint/js": "9.30.1",
|
||||
"@graphql-codegen/add": "5.0.3",
|
||||
"@graphql-codegen/cli": "5.0.7",
|
||||
"@graphql-codegen/fragment-matcher": "5.1.0",
|
||||
"@graphql-codegen/import-types-preset": "3.0.1",
|
||||
"@graphql-codegen/typed-document-node": "5.1.2",
|
||||
"@graphql-codegen/typescript": "4.1.6",
|
||||
"@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.4.2",
|
||||
"@nestjs/testing": "11.1.3",
|
||||
"@originjs/vite-plugin-commonjs": "1.0.3",
|
||||
"@rollup/plugin-node-resolve": "16.0.1",
|
||||
"@swc/core": "1.12.11",
|
||||
"@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/graphql-fields": "1.3.9",
|
||||
"@types/graphql-type-uuid": "0.2.6",
|
||||
"@types/ini": "4.1.1",
|
||||
"@types/ip": "1.1.3",
|
||||
"@types/lodash": "4.17.20",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/mustache": "4.2.6",
|
||||
"@types/node": "22.16.2",
|
||||
"@types/pify": "6.1.0",
|
||||
"@types/semver": "7.7.0",
|
||||
"@types/sendmail": "1.4.7",
|
||||
"@types/stoppable": "1.1.3",
|
||||
"@types/strftime": "0.9.8",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/wtfnode": "0.7.3",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-n": "^17.0.0",
|
||||
"eslint-plugin-no-relative-import-paths": "^1.6.1",
|
||||
"eslint-plugin-prettier": "^5.2.3",
|
||||
"graphql-codegen-typescript-validation-schema": "^0.17.0",
|
||||
"jiti": "^2.4.0",
|
||||
"nodemon": "^3.1.7",
|
||||
"prettier": "^3.5.2",
|
||||
"rollup-plugin-node-externals": "^8.0.0",
|
||||
"standard-version": "^9.5.0",
|
||||
"tsx": "^4.19.2",
|
||||
"type-fest": "^4.37.0",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.13.0",
|
||||
"unplugin-swc": "^1.5.1",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-node": "^5.0.0",
|
||||
"vite-tsconfig-paths": "^5.1.0",
|
||||
"vitest": "^3.0.5",
|
||||
"zx": "^8.3.2"
|
||||
"eslint": "9.30.1",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-n": "17.21.0",
|
||||
"eslint-plugin-no-relative-import-paths": "1.6.1",
|
||||
"eslint-plugin-prettier": "5.5.1",
|
||||
"graphql-codegen-typescript-validation-schema": "0.17.1",
|
||||
"jiti": "2.4.2",
|
||||
"nodemon": "3.1.10",
|
||||
"prettier": "3.6.2",
|
||||
"rollup-plugin-node-externals": "8.0.1",
|
||||
"commit-and-tag-version": "9.6.0",
|
||||
"tsx": "4.20.3",
|
||||
"type-fest": "4.41.0",
|
||||
"typescript": "5.8.3",
|
||||
"typescript-eslint": "8.36.0",
|
||||
"unplugin-swc": "1.5.5",
|
||||
"vite": "7.0.3",
|
||||
"vite-plugin-node": "7.0.0",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "3.2.4",
|
||||
"zx": "8.6.2"
|
||||
},
|
||||
"overrides": {
|
||||
"eslint": {
|
||||
"jiti": "2"
|
||||
"jiti": "2.4.2"
|
||||
},
|
||||
"@as-integrations/fastify": {
|
||||
"fastify": "$fastify"
|
||||
@@ -226,5 +228,5 @@
|
||||
}
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.8.1"
|
||||
"packageManager": "pnpm@10.13.1"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env zx
|
||||
import { mkdir, readFile, writeFile } from 'fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
import { exit } from 'process';
|
||||
|
||||
import type { PackageJson } from 'type-fest';
|
||||
@@ -10,8 +12,48 @@ import { getDeploymentVersion } from './get-deployment-version.js';
|
||||
type ApiPackageJson = PackageJson & {
|
||||
version: string;
|
||||
peerDependencies: Record<string, string>;
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Map of workspace packages to vendor into production builds.
|
||||
* Key: package name, Value: path from monorepo root to the package directory
|
||||
*/
|
||||
const WORKSPACE_PACKAGES_TO_VENDOR = {
|
||||
'@unraid/shared': 'packages/unraid-shared',
|
||||
'unraid-api-plugin-connect': 'packages/unraid-api-plugin-connect',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Packs a workspace package and installs it as a tarball dependency.
|
||||
*/
|
||||
const packAndInstallWorkspacePackage = async (pkgName: string, pkgPath: string, tempDir: string) => {
|
||||
const [fullPkgPath, fullTempDir] = [resolve(pkgPath), resolve(tempDir)];
|
||||
if (!existsSync(fullPkgPath)) {
|
||||
console.warn(`Workspace package ${pkgName} not found at ${fullPkgPath}. Skipping.`);
|
||||
return;
|
||||
}
|
||||
console.log(`Building and packing workspace package ${pkgName}...`);
|
||||
// Pack the package to a tarball
|
||||
const packedResult = await $`pnpm --filter ${pkgName} pack --pack-destination ${fullTempDir}`;
|
||||
const tarballPath = packedResult.lines().at(-1)!;
|
||||
const tarballName = basename(tarballPath);
|
||||
|
||||
// Install the tarball
|
||||
const tarballPattern = join(fullTempDir, tarballName);
|
||||
await $`npm install ${tarballPattern}`;
|
||||
};
|
||||
|
||||
/**------------------------------------------------------------------------
|
||||
* Build Script
|
||||
*
|
||||
* Builds & vendors the API for deployment to an Unraid server.
|
||||
*
|
||||
* Places artifacts in the `deploy/` folder:
|
||||
* - release/ contains source code & assets
|
||||
* - node-modules-archive/ contains tarball of node_modules
|
||||
*------------------------------------------------------------------------**/
|
||||
|
||||
try {
|
||||
// Create release and pack directories
|
||||
await mkdir('./deploy/release', { recursive: true });
|
||||
@@ -30,6 +72,20 @@ try {
|
||||
|
||||
// Update the package.json version to the deployment version
|
||||
parsedPackageJson.version = deploymentVersion;
|
||||
|
||||
/**---------------------------------------------
|
||||
* Handle workspace runtime dependencies
|
||||
*--------------------------------------------*/
|
||||
const workspaceDeps = Object.keys(WORKSPACE_PACKAGES_TO_VENDOR);
|
||||
if (workspaceDeps.length > 0) {
|
||||
console.log(`Stripping workspace deps from package.json: ${workspaceDeps.join(', ')}`);
|
||||
workspaceDeps.forEach((dep) => {
|
||||
if (parsedPackageJson.dependencies?.[dep]) {
|
||||
delete parsedPackageJson.dependencies[dep];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// omit dev dependencies from vendored dependencies in release build
|
||||
parsedPackageJson.devDependencies = {};
|
||||
|
||||
@@ -49,13 +105,26 @@ try {
|
||||
|
||||
await writeFile('package.json', JSON.stringify(parsedPackageJson, null, 4));
|
||||
|
||||
const sudoCheck = await $`command -v sudo`.nothrow();
|
||||
const SUDO = sudoCheck.exitCode === 0 ? 'sudo' : '';
|
||||
await $`${SUDO} chown -R 0:0 node_modules`;
|
||||
/** After npm install, vendor workspace packages via pack/install */
|
||||
if (workspaceDeps.length > 0) {
|
||||
console.log('Vendoring workspace packages...');
|
||||
const tempDir = './packages';
|
||||
await mkdir(tempDir, { recursive: true });
|
||||
|
||||
await $`XZ_OPT=-5 tar -cJf packed-node-modules.tar.xz node_modules`;
|
||||
await $`mv packed-node-modules.tar.xz ../`;
|
||||
await $`${SUDO} rm -rf node_modules`;
|
||||
for (const dep of workspaceDeps) {
|
||||
const pkgPath =
|
||||
WORKSPACE_PACKAGES_TO_VENDOR[dep as keyof typeof WORKSPACE_PACKAGES_TO_VENDOR];
|
||||
// The extra '../../../' prefix adjusts for the fact that we're in the pack directory.
|
||||
// this way, pkgPath can be defined relative to the monorepo root.
|
||||
await packAndInstallWorkspacePackage(dep, join('../../../', pkgPath), tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean the release directory
|
||||
await $`rm -rf ../release/*`;
|
||||
|
||||
// Copy other files to release directory
|
||||
await $`cp -r ./* ../release/`;
|
||||
|
||||
// chmod the cli
|
||||
await $`chmod +x ./dist/cli.js`;
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* This AI-generated script copies workspace plugin dist folders to the dist/plugins directory
|
||||
* to ensure they're available for dynamic imports in production.
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Get the package.json to find workspace dependencies
|
||||
const packageJsonPath = path.resolve(__dirname, '../package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
|
||||
// Create the plugins directory if it doesn't exist
|
||||
const pluginsDir = path.resolve(__dirname, '../dist/plugins');
|
||||
if (!fs.existsSync(pluginsDir)) {
|
||||
fs.mkdirSync(pluginsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Find all workspace plugins
|
||||
const pluginPrefix = 'unraid-api-plugin-';
|
||||
const workspacePlugins = Object.keys(packageJson.peerDependencies || {}).filter((pkgName) =>
|
||||
pkgName.startsWith(pluginPrefix)
|
||||
);
|
||||
|
||||
// Copy each plugin's dist folder to the plugins directory
|
||||
for (const pkgName of workspacePlugins) {
|
||||
const pluginPath = path.resolve(__dirname, `../../packages/${pkgName}`);
|
||||
const pluginDistPath = path.resolve(pluginPath, 'dist');
|
||||
const targetPath = path.resolve(pluginsDir, pkgName);
|
||||
|
||||
console.log(`Building ${pkgName}...`);
|
||||
try {
|
||||
execSync('pnpm build', {
|
||||
cwd: pluginPath,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
console.log(`Successfully built ${pkgName}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to build ${pkgName}:`, error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(pluginDistPath)) {
|
||||
console.warn(`Plugin ${pkgName} dist folder not found at ${pluginDistPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Copying ${pkgName} dist folder to ${targetPath}`);
|
||||
fs.mkdirSync(targetPath, { recursive: true });
|
||||
fs.cpSync(pluginDistPath, targetPath, { recursive: true });
|
||||
console.log(`Successfully copied ${pkgName} dist folder`);
|
||||
}
|
||||
|
||||
console.log('Plugin dist folders copied successfully');
|
||||
137
api/src/__test__/config/api-config.test.ts
Normal file
137
api/src/__test__/config/api-config.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiConfigPersistence } from '@app/unraid-api/config/api-config.module.js';
|
||||
import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.helper.js';
|
||||
|
||||
describe('ApiConfigPersistence', () => {
|
||||
let service: ApiConfigPersistence;
|
||||
let configService: ConfigService;
|
||||
let persistenceHelper: ConfigPersistenceHelper;
|
||||
|
||||
beforeEach(() => {
|
||||
configService = {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
} as any;
|
||||
|
||||
persistenceHelper = {} as ConfigPersistenceHelper;
|
||||
service = new ApiConfigPersistence(configService, persistenceHelper);
|
||||
});
|
||||
|
||||
describe('convertLegacyConfig', () => {
|
||||
it('should migrate sandbox from string "yes" to boolean true', () => {
|
||||
const legacyConfig = {
|
||||
local: { sandbox: 'yes' },
|
||||
api: { extraOrigins: '' },
|
||||
remote: { ssoSubIds: '' },
|
||||
};
|
||||
|
||||
const result = service.convertLegacyConfig(legacyConfig);
|
||||
|
||||
expect(result.sandbox).toBe(true);
|
||||
});
|
||||
|
||||
it('should migrate sandbox from string "no" to boolean false', () => {
|
||||
const legacyConfig = {
|
||||
local: { sandbox: 'no' },
|
||||
api: { extraOrigins: '' },
|
||||
remote: { ssoSubIds: '' },
|
||||
};
|
||||
|
||||
const result = service.convertLegacyConfig(legacyConfig);
|
||||
|
||||
expect(result.sandbox).toBe(false);
|
||||
});
|
||||
|
||||
it('should migrate extraOrigins from comma-separated string to array', () => {
|
||||
const legacyConfig = {
|
||||
local: { sandbox: 'no' },
|
||||
api: { extraOrigins: 'https://example.com,https://test.com' },
|
||||
remote: { ssoSubIds: '' },
|
||||
};
|
||||
|
||||
const result = service.convertLegacyConfig(legacyConfig);
|
||||
|
||||
expect(result.extraOrigins).toEqual(['https://example.com', 'https://test.com']);
|
||||
});
|
||||
|
||||
it('should filter out non-HTTP origins from extraOrigins', () => {
|
||||
const legacyConfig = {
|
||||
local: { sandbox: 'no' },
|
||||
api: {
|
||||
extraOrigins: 'https://example.com,invalid-origin,http://test.com,ftp://bad.com',
|
||||
},
|
||||
remote: { ssoSubIds: '' },
|
||||
};
|
||||
|
||||
const result = service.convertLegacyConfig(legacyConfig);
|
||||
|
||||
expect(result.extraOrigins).toEqual(['https://example.com', 'http://test.com']);
|
||||
});
|
||||
|
||||
it('should handle empty extraOrigins string', () => {
|
||||
const legacyConfig = {
|
||||
local: { sandbox: 'no' },
|
||||
api: { extraOrigins: '' },
|
||||
remote: { ssoSubIds: '' },
|
||||
};
|
||||
|
||||
const result = service.convertLegacyConfig(legacyConfig);
|
||||
|
||||
expect(result.extraOrigins).toEqual([]);
|
||||
});
|
||||
|
||||
it('should migrate ssoSubIds from comma-separated string to array', () => {
|
||||
const legacyConfig = {
|
||||
local: { sandbox: 'no' },
|
||||
api: { extraOrigins: '' },
|
||||
remote: { ssoSubIds: 'user1,user2,user3' },
|
||||
};
|
||||
|
||||
const result = service.convertLegacyConfig(legacyConfig);
|
||||
|
||||
expect(result.ssoSubIds).toEqual(['user1', 'user2', 'user3']);
|
||||
});
|
||||
|
||||
it('should handle empty ssoSubIds string', () => {
|
||||
const legacyConfig = {
|
||||
local: { sandbox: 'no' },
|
||||
api: { extraOrigins: '' },
|
||||
remote: { ssoSubIds: '' },
|
||||
};
|
||||
|
||||
const result = service.convertLegacyConfig(legacyConfig);
|
||||
|
||||
expect(result.ssoSubIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle undefined config sections', () => {
|
||||
const legacyConfig = {};
|
||||
|
||||
const result = service.convertLegacyConfig(legacyConfig);
|
||||
|
||||
expect(result.sandbox).toBe(false);
|
||||
expect(result.extraOrigins).toEqual([]);
|
||||
expect(result.ssoSubIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle complete migration with all fields', () => {
|
||||
const legacyConfig = {
|
||||
local: { sandbox: 'yes' },
|
||||
api: { extraOrigins: 'https://app1.example.com,https://app2.example.com' },
|
||||
remote: { ssoSubIds: 'sub1,sub2,sub3' },
|
||||
};
|
||||
|
||||
const result = service.convertLegacyConfig(legacyConfig);
|
||||
|
||||
expect(result.sandbox).toBe(true);
|
||||
expect(result.extraOrigins).toEqual([
|
||||
'https://app1.example.com',
|
||||
'https://app2.example.com',
|
||||
]);
|
||||
expect(result.ssoSubIds).toEqual(['sub1', 'sub2', 'sub3']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { checkMothershipAuthentication } from '@app/graphql/resolvers/query/cloud/check-mothership-authentication.js';
|
||||
|
||||
test('It fails to authenticate with mothership with no credentials', async () => {
|
||||
try {
|
||||
const packageJson = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'));
|
||||
await expect(
|
||||
checkMothershipAuthentication('BAD', 'BAD')
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`[Error: Failed to connect to https://mothership.unraid.net/ws with a "426" HTTP error.]`
|
||||
);
|
||||
expect(packageJson.version).not.toBeNull();
|
||||
await expect(
|
||||
checkMothershipAuthentication(packageJson.version, 'BAD_API_KEY')
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Invalid credentials]`);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Timeout')) {
|
||||
// Test succeeds on timeout
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
374
api/src/__test__/graphql/resolvers/rclone-api.service.test.ts
Normal file
374
api/src/__test__/graphql/resolvers/rclone-api.service.test.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { HTTPError } from 'got';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
|
||||
import {
|
||||
CreateRCloneRemoteDto,
|
||||
DeleteRCloneRemoteDto,
|
||||
GetRCloneJobStatusDto,
|
||||
GetRCloneRemoteConfigDto,
|
||||
GetRCloneRemoteDetailsDto,
|
||||
RCloneStartBackupInput,
|
||||
UpdateRCloneRemoteDto,
|
||||
} from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
|
||||
|
||||
vi.mock('got');
|
||||
vi.mock('execa');
|
||||
vi.mock('p-retry');
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
}));
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
mkdir: vi.fn(),
|
||||
rm: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
}));
|
||||
vi.mock('@app/core/log.js', () => ({
|
||||
sanitizeParams: vi.fn((params) => params),
|
||||
}));
|
||||
vi.mock('@app/store/index.js', () => ({
|
||||
getters: {
|
||||
paths: () => ({
|
||||
'rclone-socket': '/tmp/rclone.sock',
|
||||
'log-base': '/var/log',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock NestJS Logger to suppress logs during tests
|
||||
vi.mock('@nestjs/common', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@nestjs/common')>();
|
||||
return {
|
||||
...original,
|
||||
Logger: vi.fn(() => ({
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('RCloneApiService', () => {
|
||||
let service: RCloneApiService;
|
||||
let mockGot: any;
|
||||
let mockExeca: any;
|
||||
let mockPRetry: any;
|
||||
let mockExistsSync: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const { default: got } = await import('got');
|
||||
const { execa } = await import('execa');
|
||||
const pRetry = await import('p-retry');
|
||||
const { existsSync } = await import('node:fs');
|
||||
|
||||
mockGot = vi.mocked(got);
|
||||
mockExeca = vi.mocked(execa);
|
||||
mockPRetry = vi.mocked(pRetry.default);
|
||||
mockExistsSync = vi.mocked(existsSync);
|
||||
|
||||
mockGot.post = vi.fn().mockResolvedValue({ body: {} });
|
||||
mockExeca.mockReturnValue({
|
||||
on: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
killed: false,
|
||||
pid: 12345,
|
||||
} as any);
|
||||
mockPRetry.mockResolvedValue(undefined);
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
service = new RCloneApiService();
|
||||
await service.onModuleInit();
|
||||
});
|
||||
|
||||
describe('getProviders', () => {
|
||||
it('should return list of providers', async () => {
|
||||
const mockProviders = [
|
||||
{ name: 'aws', prefix: 's3', description: 'Amazon S3' },
|
||||
{ name: 'google', prefix: 'drive', description: 'Google Drive' },
|
||||
];
|
||||
mockGot.post.mockResolvedValue({
|
||||
body: { providers: mockProviders },
|
||||
});
|
||||
|
||||
const result = await service.getProviders();
|
||||
|
||||
expect(result).toEqual(mockProviders);
|
||||
expect(mockGot.post).toHaveBeenCalledWith(
|
||||
'http://unix:/tmp/rclone.sock:/config/providers',
|
||||
expect.objectContaining({
|
||||
json: {},
|
||||
responseType: 'json',
|
||||
enableUnixSockets: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no providers', async () => {
|
||||
mockGot.post.mockResolvedValue({ body: {} });
|
||||
|
||||
const result = await service.getProviders();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listRemotes', () => {
|
||||
it('should return list of remotes', async () => {
|
||||
const mockRemotes = ['backup-s3', 'drive-storage'];
|
||||
mockGot.post.mockResolvedValue({
|
||||
body: { remotes: mockRemotes },
|
||||
});
|
||||
|
||||
const result = await service.listRemotes();
|
||||
|
||||
expect(result).toEqual(mockRemotes);
|
||||
expect(mockGot.post).toHaveBeenCalledWith(
|
||||
'http://unix:/tmp/rclone.sock:/config/listremotes',
|
||||
expect.objectContaining({
|
||||
json: {},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no remotes', async () => {
|
||||
mockGot.post.mockResolvedValue({ body: {} });
|
||||
|
||||
const result = await service.listRemotes();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRemoteDetails', () => {
|
||||
it('should return remote details', async () => {
|
||||
const input: GetRCloneRemoteDetailsDto = { name: 'test-remote' };
|
||||
const mockConfig = { type: 's3', provider: 'AWS' };
|
||||
mockGot.post.mockResolvedValue({ body: mockConfig });
|
||||
|
||||
const result = await service.getRemoteDetails(input);
|
||||
|
||||
expect(result).toEqual(mockConfig);
|
||||
expect(mockGot.post).toHaveBeenCalledWith(
|
||||
'http://unix:/tmp/rclone.sock:/config/get',
|
||||
expect.objectContaining({
|
||||
json: { name: 'test-remote' },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRemoteConfig', () => {
|
||||
it('should return remote configuration', async () => {
|
||||
const input: GetRCloneRemoteConfigDto = { name: 'test-remote' };
|
||||
const mockConfig = { type: 's3', access_key_id: 'AKIA...' };
|
||||
mockGot.post.mockResolvedValue({ body: mockConfig });
|
||||
|
||||
const result = await service.getRemoteConfig(input);
|
||||
|
||||
expect(result).toEqual(mockConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRemote', () => {
|
||||
it('should create a new remote', async () => {
|
||||
const input: CreateRCloneRemoteDto = {
|
||||
name: 'new-remote',
|
||||
type: 's3',
|
||||
parameters: { access_key_id: 'AKIA...', secret_access_key: 'secret' },
|
||||
};
|
||||
const mockResponse = { success: true };
|
||||
mockGot.post.mockResolvedValue({ body: mockResponse });
|
||||
|
||||
const result = await service.createRemote(input);
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(mockGot.post).toHaveBeenCalledWith(
|
||||
'http://unix:/tmp/rclone.sock:/config/create',
|
||||
expect.objectContaining({
|
||||
json: {
|
||||
name: 'new-remote',
|
||||
type: 's3',
|
||||
parameters: { access_key_id: 'AKIA...', secret_access_key: 'secret' },
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRemote', () => {
|
||||
it('should update an existing remote', async () => {
|
||||
const input: UpdateRCloneRemoteDto = {
|
||||
name: 'existing-remote',
|
||||
parameters: { access_key_id: 'NEW_AKIA...' },
|
||||
};
|
||||
const mockResponse = { success: true };
|
||||
mockGot.post.mockResolvedValue({ body: mockResponse });
|
||||
|
||||
const result = await service.updateRemote(input);
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(mockGot.post).toHaveBeenCalledWith(
|
||||
'http://unix:/tmp/rclone.sock:/config/update',
|
||||
expect.objectContaining({
|
||||
json: {
|
||||
name: 'existing-remote',
|
||||
access_key_id: 'NEW_AKIA...',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteRemote', () => {
|
||||
it('should delete a remote', async () => {
|
||||
const input: DeleteRCloneRemoteDto = { name: 'remote-to-delete' };
|
||||
const mockResponse = { success: true };
|
||||
mockGot.post.mockResolvedValue({ body: mockResponse });
|
||||
|
||||
const result = await service.deleteRemote(input);
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(mockGot.post).toHaveBeenCalledWith(
|
||||
'http://unix:/tmp/rclone.sock:/config/delete',
|
||||
expect.objectContaining({
|
||||
json: { name: 'remote-to-delete' },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startBackup', () => {
|
||||
it('should start a backup operation', async () => {
|
||||
const input: RCloneStartBackupInput = {
|
||||
srcPath: '/source/path',
|
||||
dstPath: 'remote:backup/path',
|
||||
options: { delete_on: 'dst' },
|
||||
};
|
||||
const mockResponse = { jobid: 'job-123' };
|
||||
mockGot.post.mockResolvedValue({ body: mockResponse });
|
||||
|
||||
const result = await service.startBackup(input);
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(mockGot.post).toHaveBeenCalledWith(
|
||||
'http://unix:/tmp/rclone.sock:/sync/copy',
|
||||
expect.objectContaining({
|
||||
json: {
|
||||
srcFs: '/source/path',
|
||||
dstFs: 'remote:backup/path',
|
||||
delete_on: 'dst',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getJobStatus', () => {
|
||||
it('should return job status', async () => {
|
||||
const input: GetRCloneJobStatusDto = { jobId: 'job-123' };
|
||||
const mockStatus = { status: 'running', progress: 0.5 };
|
||||
mockGot.post.mockResolvedValue({ body: mockStatus });
|
||||
|
||||
const result = await service.getJobStatus(input);
|
||||
|
||||
expect(result).toEqual(mockStatus);
|
||||
expect(mockGot.post).toHaveBeenCalledWith(
|
||||
'http://unix:/tmp/rclone.sock:/job/status',
|
||||
expect.objectContaining({
|
||||
json: { jobid: 'job-123' },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listRunningJobs', () => {
|
||||
it('should return list of running jobs', async () => {
|
||||
const mockJobs = [
|
||||
{ id: 'job-1', status: 'running' },
|
||||
{ id: 'job-2', status: 'finished' },
|
||||
];
|
||||
mockGot.post.mockResolvedValue({ body: mockJobs });
|
||||
|
||||
const result = await service.listRunningJobs();
|
||||
|
||||
expect(result).toEqual(mockJobs);
|
||||
expect(mockGot.post).toHaveBeenCalledWith(
|
||||
'http://unix:/tmp/rclone.sock:/job/list',
|
||||
expect.objectContaining({
|
||||
json: {},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle HTTP errors with detailed messages', async () => {
|
||||
const httpError = {
|
||||
name: 'HTTPError',
|
||||
message: 'Request failed',
|
||||
response: {
|
||||
statusCode: 500,
|
||||
body: JSON.stringify({ error: 'Internal server error' }),
|
||||
},
|
||||
};
|
||||
Object.setPrototypeOf(httpError, HTTPError.prototype);
|
||||
mockGot.post.mockRejectedValue(httpError);
|
||||
|
||||
await expect(service.getProviders()).rejects.toThrow(
|
||||
'Rclone API Error (config/providers, HTTP 500): Rclone Error: Internal server error'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle HTTP errors with empty response body', async () => {
|
||||
const httpError = {
|
||||
name: 'HTTPError',
|
||||
message: 'Request failed',
|
||||
response: {
|
||||
statusCode: 404,
|
||||
body: '',
|
||||
},
|
||||
};
|
||||
Object.setPrototypeOf(httpError, HTTPError.prototype);
|
||||
mockGot.post.mockRejectedValue(httpError);
|
||||
|
||||
await expect(service.getProviders()).rejects.toThrow(
|
||||
'Rclone API Error (config/providers, HTTP 404): Failed to process error response body. Raw body:'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle HTTP errors with malformed JSON', async () => {
|
||||
const httpError = {
|
||||
name: 'HTTPError',
|
||||
message: 'Request failed',
|
||||
response: {
|
||||
statusCode: 400,
|
||||
body: 'invalid json',
|
||||
},
|
||||
};
|
||||
Object.setPrototypeOf(httpError, HTTPError.prototype);
|
||||
mockGot.post.mockRejectedValue(httpError);
|
||||
|
||||
await expect(service.getProviders()).rejects.toThrow(
|
||||
'Rclone API Error (config/providers, HTTP 400): Failed to process error response body. Raw body: invalid json'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle non-HTTP errors', async () => {
|
||||
const networkError = new Error('Network connection failed');
|
||||
mockGot.post.mockRejectedValue(networkError);
|
||||
|
||||
await expect(service.getProviders()).rejects.toThrow('Network connection failed');
|
||||
});
|
||||
|
||||
it('should handle unknown errors', async () => {
|
||||
mockGot.post.mockRejectedValue('unknown error');
|
||||
|
||||
await expect(service.getProviders()).rejects.toThrow(
|
||||
'Unknown error calling RClone API (config/providers) with params {}: unknown error'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,227 +0,0 @@
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { NginxUrlFields } from '@app/graphql/resolvers/subscription/network.js';
|
||||
import { type Nginx } from '@app/core/types/states/nginx.js';
|
||||
import {
|
||||
getServerIps,
|
||||
getUrlForField,
|
||||
getUrlForServer,
|
||||
} from '@app/graphql/resolvers/subscription/network.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { loadConfigFile } from '@app/store/modules/config.js';
|
||||
import { loadStateFiles } from '@app/store/modules/emhttp.js';
|
||||
import { URL_TYPE } from '@app/unraid-api/graph/resolvers/connect/connect.model.js';
|
||||
|
||||
test.each([
|
||||
[{ httpPort: 80, httpsPort: 443, url: 'my-default-url.com' }],
|
||||
[{ httpPort: 123, httpsPort: 443, url: 'my-default-url.com' }],
|
||||
[{ httpPort: 80, httpsPort: 12_345, url: 'my-default-url.com' }],
|
||||
[{ httpPort: 212, httpsPort: 3_233, url: 'my-default-url.com' }],
|
||||
[{ httpPort: 80, httpsPort: 443, url: 'https://BROKEN_URL' }],
|
||||
])('getUrlForField', ({ httpPort, httpsPort, url }) => {
|
||||
const responseInsecure = getUrlForField({
|
||||
port: httpPort,
|
||||
url,
|
||||
});
|
||||
|
||||
const responseSecure = getUrlForField({
|
||||
portSsl: httpsPort,
|
||||
url,
|
||||
});
|
||||
if (httpPort === 80) {
|
||||
expect(responseInsecure.port).toBe('');
|
||||
} else {
|
||||
expect(responseInsecure.port).toBe(httpPort.toString());
|
||||
}
|
||||
|
||||
if (httpsPort === 443) {
|
||||
expect(responseSecure.port).toBe('');
|
||||
} else {
|
||||
expect(responseSecure.port).toBe(httpsPort.toString());
|
||||
}
|
||||
});
|
||||
|
||||
test('getUrlForServer - field exists, ssl disabled', () => {
|
||||
const result = getUrlForServer({
|
||||
nginx: {
|
||||
lanIp: '192.168.1.1',
|
||||
sslEnabled: false,
|
||||
httpPort: 123,
|
||||
httpsPort: 445,
|
||||
} as const as Nginx,
|
||||
field: 'lanIp',
|
||||
});
|
||||
expect(result).toMatchInlineSnapshot('"http://192.168.1.1:123/"');
|
||||
});
|
||||
|
||||
test('getUrlForServer - field exists, ssl yes', () => {
|
||||
const result = getUrlForServer({
|
||||
nginx: {
|
||||
lanIp: '192.168.1.1',
|
||||
sslEnabled: true,
|
||||
sslMode: 'yes',
|
||||
httpPort: 123,
|
||||
httpsPort: 445,
|
||||
} as const as Nginx,
|
||||
field: 'lanIp',
|
||||
});
|
||||
expect(result).toMatchInlineSnapshot('"https://192.168.1.1:445/"');
|
||||
});
|
||||
|
||||
test('getUrlForServer - field exists, ssl yes, port empty', () => {
|
||||
const result = getUrlForServer({
|
||||
nginx: {
|
||||
lanIp: '192.168.1.1',
|
||||
sslEnabled: true,
|
||||
sslMode: 'yes',
|
||||
httpPort: 80,
|
||||
httpsPort: 443,
|
||||
} as const as Nginx,
|
||||
field: 'lanIp',
|
||||
});
|
||||
expect(result).toMatchInlineSnapshot('"https://192.168.1.1/"');
|
||||
});
|
||||
|
||||
test('getUrlForServer - field exists, ssl auto', async () => {
|
||||
const getResult = async () =>
|
||||
getUrlForServer({
|
||||
nginx: {
|
||||
lanIp: '192.168.1.1',
|
||||
sslEnabled: true,
|
||||
sslMode: 'auto',
|
||||
httpPort: 123,
|
||||
httpsPort: 445,
|
||||
} as const as Nginx,
|
||||
field: 'lanIp',
|
||||
});
|
||||
await expect(getResult).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`[Error: Cannot get IP Based URL for field: "lanIp" SSL mode auto]`
|
||||
);
|
||||
});
|
||||
|
||||
test('getUrlForServer - field does not exist, ssl disabled', async () => {
|
||||
const getResult = async () =>
|
||||
getUrlForServer({
|
||||
nginx: { lanIp: '192.168.1.1', sslEnabled: false, sslMode: 'no' } as const as Nginx,
|
||||
ports: {
|
||||
port: ':123',
|
||||
portSsl: ':445',
|
||||
defaultUrl: new URL('https://my-default-url.unraid.net'),
|
||||
},
|
||||
// @ts-expect-error Field doesn't exist
|
||||
field: 'idontexist',
|
||||
});
|
||||
await expect(getResult).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`[Error: IP URL Resolver: Could not resolve any access URL for field: "idontexist", is FQDN?: false]`
|
||||
);
|
||||
});
|
||||
|
||||
test('getUrlForServer - FQDN - field exists, port non-empty', () => {
|
||||
const result = getUrlForServer({
|
||||
nginx: { lanFqdn: 'my-fqdn.unraid.net', httpsPort: 445 } as unknown as Nginx,
|
||||
field: 'lanFqdn' as NginxUrlFields,
|
||||
});
|
||||
expect(result).toMatchInlineSnapshot('"https://my-fqdn.unraid.net:445/"');
|
||||
});
|
||||
|
||||
test('getUrlForServer - FQDN - field exists, port empty', () => {
|
||||
const result = getUrlForServer({
|
||||
nginx: { lanFqdn: 'my-fqdn.unraid.net', httpPort: 80, httpsPort: 443 } as unknown as Nginx,
|
||||
field: 'lanFqdn' as NginxUrlFields,
|
||||
});
|
||||
expect(result).toMatchInlineSnapshot('"https://my-fqdn.unraid.net/"');
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
{
|
||||
nginx: {
|
||||
lanFqdn: 'my-fqdn.unraid.net',
|
||||
sslEnabled: false,
|
||||
sslMode: 'no',
|
||||
httpPort: 80,
|
||||
httpsPort: 443,
|
||||
} as unknown as Nginx,
|
||||
field: 'lanFqdn' as NginxUrlFields,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
nginx: {
|
||||
wanFqdn: 'my-fqdn.unraid.net',
|
||||
sslEnabled: true,
|
||||
sslMode: 'yes',
|
||||
httpPort: 80,
|
||||
httpsPort: 443,
|
||||
} as unknown as Nginx,
|
||||
field: 'wanFqdn' as NginxUrlFields,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
nginx: {
|
||||
wanFqdn6: 'my-fqdn.unraid.net',
|
||||
sslEnabled: true,
|
||||
sslMode: 'auto',
|
||||
httpPort: 80,
|
||||
httpsPort: 443,
|
||||
} as unknown as Nginx,
|
||||
field: 'wanFqdn6' as NginxUrlFields,
|
||||
},
|
||||
],
|
||||
])('getUrlForServer - FQDN', ({ nginx, field }) => {
|
||||
const result = getUrlForServer({ nginx, field });
|
||||
expect(result.toString()).toBe('https://my-fqdn.unraid.net/');
|
||||
});
|
||||
|
||||
test('getUrlForServer - field does not exist, ssl disabled', async () => {
|
||||
const getResult = async () =>
|
||||
getUrlForServer({
|
||||
nginx: { lanFqdn: 'my-fqdn.unraid.net' } as unknown as Nginx,
|
||||
ports: { portSsl: '', port: '', defaultUrl: new URL('https://my-default-url.unraid.net') },
|
||||
// @ts-expect-error Field doesn't exist
|
||||
field: 'idontexist',
|
||||
});
|
||||
await expect(getResult).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`[Error: IP URL Resolver: Could not resolve any access URL for field: "idontexist", is FQDN?: false]`
|
||||
);
|
||||
});
|
||||
|
||||
test('integration test, loading nginx ini and generating all URLs', async () => {
|
||||
await store.dispatch(loadStateFiles());
|
||||
await store.dispatch(loadConfigFile());
|
||||
|
||||
// Instead of mocking the getServerIps function, we'll use the actual function
|
||||
// and verify the structure of the returned URLs
|
||||
const urls = getServerIps();
|
||||
|
||||
// Verify that we have URLs
|
||||
expect(urls.urls.length).toBeGreaterThan(0);
|
||||
expect(urls.errors.length).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Verify that each URL has the expected structure
|
||||
urls.urls.forEach((url) => {
|
||||
expect(url).toHaveProperty('ipv4');
|
||||
expect(url).toHaveProperty('name');
|
||||
expect(url).toHaveProperty('type');
|
||||
|
||||
// Verify that the URL matches the expected pattern based on its type
|
||||
if (url.type === URL_TYPE.DEFAULT) {
|
||||
expect(url.ipv4?.toString()).toMatch(/^https:\/\/.*:\d+\/$/);
|
||||
expect(url.ipv6?.toString()).toMatch(/^https:\/\/.*:\d+\/$/);
|
||||
} else if (url.type === URL_TYPE.LAN) {
|
||||
expect(url.ipv4?.toString()).toMatch(/^https:\/\/.*:\d+\/$/);
|
||||
} else if (url.type === URL_TYPE.MDNS) {
|
||||
expect(url.ipv4?.toString()).toMatch(/^https:\/\/.*:\d+\/$/);
|
||||
} else if (url.type === URL_TYPE.WIREGUARD) {
|
||||
expect(url.ipv4?.toString()).toMatch(/^https:\/\/.*:\d+\/$/);
|
||||
}
|
||||
});
|
||||
|
||||
// Verify that the error message contains the expected text
|
||||
if (urls.errors.length > 0) {
|
||||
expect(urls.errors[0].message).toContain(
|
||||
'IP URL Resolver: Could not resolve any access URL for field:'
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Returns paths 1`] = `
|
||||
[
|
||||
"core",
|
||||
"unraid-api-base",
|
||||
"unraid-data",
|
||||
"docker-autostart",
|
||||
"docker-socket",
|
||||
"rclone-socket",
|
||||
"parity-checks",
|
||||
"htpasswd",
|
||||
"emhttpd-socket",
|
||||
"states",
|
||||
"dynamix-base",
|
||||
"dynamix-config",
|
||||
"myservers-base",
|
||||
"myservers-config",
|
||||
"myservers-config-states",
|
||||
"myservers-env",
|
||||
"myservers-keepalive",
|
||||
"keyfile-base",
|
||||
"machine-id",
|
||||
"log-base",
|
||||
"unraid-log-base",
|
||||
"var-run",
|
||||
"auth-sessions",
|
||||
"auth-keys",
|
||||
"passwd",
|
||||
"libvirt-pid",
|
||||
"activationBase",
|
||||
"webGuiBase",
|
||||
"identConfig",
|
||||
"activation",
|
||||
"boot",
|
||||
"webgui",
|
||||
]
|
||||
`;
|
||||
@@ -1,31 +1,14 @@
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { GraphQLClient } from '@app/mothership/graphql-client.js';
|
||||
import { stopPingTimeoutJobs } from '@app/mothership/jobs/ping-timeout-jobs.js';
|
||||
import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status.js';
|
||||
import { setupRemoteAccessThunk } from '@app/store/actions/setup-remote-access.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { MyServersConfigMemory } from '@app/types/my-servers-config.js';
|
||||
import { MinigraphStatus } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js';
|
||||
import {
|
||||
WAN_ACCESS_TYPE,
|
||||
WAN_FORWARD_TYPE,
|
||||
} from '@app/unraid-api/graph/resolvers/connect/connect.model.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@app/core/pubsub.js', () => {
|
||||
const mockPublish = vi.fn();
|
||||
return {
|
||||
pubsub: {
|
||||
publish: mockPublish,
|
||||
},
|
||||
PUBSUB_CHANNEL: {
|
||||
OWNER: 'OWNER',
|
||||
SERVERS: 'SERVERS',
|
||||
},
|
||||
__esModule: true,
|
||||
default: {
|
||||
describe.skip('config tests', () => {
|
||||
// Mock dependencies
|
||||
vi.mock('@app/core/pubsub.js', () => {
|
||||
const mockPublish = vi.fn();
|
||||
return {
|
||||
pubsub: {
|
||||
publish: mockPublish,
|
||||
},
|
||||
@@ -33,278 +16,288 @@ vi.mock('@app/core/pubsub.js', () => {
|
||||
OWNER: 'OWNER',
|
||||
SERVERS: 'SERVERS',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Get the mock function for pubsub.publish
|
||||
const mockPublish = vi.mocked(pubsub.publish);
|
||||
|
||||
// Clear mock before each test
|
||||
beforeEach(() => {
|
||||
mockPublish.mockClear();
|
||||
});
|
||||
|
||||
vi.mock('@app/mothership/graphql-client.js', () => ({
|
||||
GraphQLClient: {
|
||||
clearInstance: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@app/mothership/jobs/ping-timeout-jobs.js', () => ({
|
||||
stopPingTimeoutJobs: vi.fn(),
|
||||
}));
|
||||
|
||||
const createConfigMatcher = (specificValues: Partial<MyServersConfigMemory> = {}) => {
|
||||
const defaultMatcher = {
|
||||
api: expect.objectContaining({
|
||||
extraOrigins: expect.any(String),
|
||||
version: expect.any(String),
|
||||
}),
|
||||
connectionStatus: expect.objectContaining({
|
||||
minigraph: expect.any(String),
|
||||
upnpStatus: expect.any(String),
|
||||
}),
|
||||
local: expect.objectContaining({
|
||||
sandbox: expect.any(String),
|
||||
}),
|
||||
nodeEnv: expect.any(String),
|
||||
remote: expect.objectContaining({
|
||||
accesstoken: expect.any(String),
|
||||
allowedOrigins: expect.any(String),
|
||||
apikey: expect.any(String),
|
||||
avatar: expect.any(String),
|
||||
dynamicRemoteAccessType: expect.any(String),
|
||||
email: expect.any(String),
|
||||
idtoken: expect.any(String),
|
||||
localApiKey: expect.any(String),
|
||||
refreshtoken: expect.any(String),
|
||||
regWizTime: expect.any(String),
|
||||
ssoSubIds: expect.any(String),
|
||||
upnpEnabled: expect.any(String),
|
||||
username: expect.any(String),
|
||||
wanaccess: expect.any(String),
|
||||
wanport: expect.any(String),
|
||||
}),
|
||||
status: expect.any(String),
|
||||
};
|
||||
|
||||
return expect.objectContaining({
|
||||
...defaultMatcher,
|
||||
...specificValues,
|
||||
__esModule: true,
|
||||
default: {
|
||||
pubsub: {
|
||||
publish: mockPublish,
|
||||
},
|
||||
PUBSUB_CHANNEL: {
|
||||
OWNER: 'OWNER',
|
||||
SERVERS: 'SERVERS',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
test('Before init returns default values for all fields', async () => {
|
||||
const state = store.getState().config;
|
||||
expect(state).toMatchSnapshot();
|
||||
}, 10_000);
|
||||
// Get the mock function for pubsub.publish
|
||||
const mockPublish = vi.mocked(pubsub.publish);
|
||||
|
||||
test('After init returns values from cfg file for all fields', async () => {
|
||||
const { loadConfigFile } = await import('@app/store/modules/config.js');
|
||||
// Clear mock before each test
|
||||
beforeEach(() => {
|
||||
mockPublish.mockClear();
|
||||
});
|
||||
|
||||
// Load cfg into store
|
||||
await store.dispatch(loadConfigFile());
|
||||
vi.mock('@app/mothership/graphql-client.js', () => ({
|
||||
GraphQLClient: {
|
||||
clearInstance: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Check if store has cfg contents loaded
|
||||
const state = store.getState().config;
|
||||
expect(state).toMatchObject(createConfigMatcher());
|
||||
});
|
||||
vi.mock('@app/mothership/jobs/ping-timeout-jobs.js', () => ({
|
||||
stopPingTimeoutJobs: vi.fn(),
|
||||
}));
|
||||
|
||||
test('updateUserConfig merges in changes to current state', async () => {
|
||||
const { loadConfigFile, updateUserConfig } = await import('@app/store/modules/config.js');
|
||||
|
||||
// Load cfg into store
|
||||
await store.dispatch(loadConfigFile());
|
||||
|
||||
// Update store
|
||||
store.dispatch(
|
||||
updateUserConfig({
|
||||
remote: { avatar: 'https://via.placeholder.com/200' },
|
||||
})
|
||||
);
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state).toMatchObject(
|
||||
createConfigMatcher({
|
||||
remote: expect.objectContaining({
|
||||
avatar: 'https://via.placeholder.com/200',
|
||||
const createConfigMatcher = (specificValues: Partial<MyServersConfigMemory> = {}) => {
|
||||
const defaultMatcher = {
|
||||
api: expect.objectContaining({
|
||||
extraOrigins: expect.any(String),
|
||||
version: expect.any(String),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
connectionStatus: expect.objectContaining({
|
||||
minigraph: expect.any(String),
|
||||
upnpStatus: expect.any(String),
|
||||
}),
|
||||
local: expect.objectContaining({
|
||||
sandbox: expect.any(String),
|
||||
}),
|
||||
nodeEnv: expect.any(String),
|
||||
remote: expect.objectContaining({
|
||||
accesstoken: expect.any(String),
|
||||
allowedOrigins: expect.any(String),
|
||||
apikey: expect.any(String),
|
||||
avatar: expect.any(String),
|
||||
dynamicRemoteAccessType: expect.any(String),
|
||||
email: expect.any(String),
|
||||
idtoken: expect.any(String),
|
||||
localApiKey: expect.any(String),
|
||||
refreshtoken: expect.any(String),
|
||||
regWizTime: expect.any(String),
|
||||
ssoSubIds: expect.any(String),
|
||||
upnpEnabled: expect.any(String),
|
||||
username: expect.any(String),
|
||||
wanaccess: expect.any(String),
|
||||
wanport: expect.any(String),
|
||||
}),
|
||||
status: expect.any(String),
|
||||
};
|
||||
|
||||
test('loginUser updates state and publishes to pubsub', async () => {
|
||||
const { loginUser } = await import('@app/store/modules/config.js');
|
||||
const userInfo = {
|
||||
email: 'test@example.com',
|
||||
avatar: 'https://via.placeholder.com/200',
|
||||
username: 'testuser',
|
||||
apikey: 'test-api-key',
|
||||
localApiKey: 'test-local-api-key',
|
||||
return expect.objectContaining({
|
||||
...defaultMatcher,
|
||||
...specificValues,
|
||||
});
|
||||
};
|
||||
|
||||
await store.dispatch(loginUser(userInfo));
|
||||
// test('Before init returns default values for all fields', async () => {
|
||||
// const state = store.getState().config;
|
||||
// expect(state).toMatchSnapshot();
|
||||
// }, 10_000);
|
||||
|
||||
expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.OWNER, {
|
||||
owner: {
|
||||
username: userInfo.username,
|
||||
url: '',
|
||||
avatar: userInfo.avatar,
|
||||
},
|
||||
test('After init returns values from cfg file for all fields', async () => {
|
||||
const { loadConfigFile } = await import('@app/store/modules/config.js');
|
||||
|
||||
// Load cfg into store
|
||||
await store.dispatch(loadConfigFile());
|
||||
|
||||
// Check if store has cfg contents loaded
|
||||
const state = store.getState().config;
|
||||
expect(state).toMatchObject(createConfigMatcher());
|
||||
});
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state).toMatchObject(
|
||||
createConfigMatcher({
|
||||
remote: expect.objectContaining(userInfo),
|
||||
})
|
||||
);
|
||||
});
|
||||
test('updateUserConfig merges in changes to current state', async () => {
|
||||
const { loadConfigFile, updateUserConfig } = await import('@app/store/modules/config.js');
|
||||
|
||||
test('logoutUser clears state and publishes to pubsub', async () => {
|
||||
const { logoutUser } = await import('@app/store/modules/config.js');
|
||||
// Load cfg into store
|
||||
await store.dispatch(loadConfigFile());
|
||||
|
||||
await store.dispatch(logoutUser({ reason: 'test logout' }));
|
||||
// Update store
|
||||
store.dispatch(
|
||||
updateUserConfig({
|
||||
remote: { avatar: 'https://via.placeholder.com/200' },
|
||||
})
|
||||
);
|
||||
|
||||
expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.SERVERS, { servers: [] });
|
||||
expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.OWNER, {
|
||||
owner: {
|
||||
username: 'root',
|
||||
url: '',
|
||||
avatar: '',
|
||||
},
|
||||
const state = store.getState().config;
|
||||
expect(state).toMatchObject(
|
||||
createConfigMatcher({
|
||||
remote: expect.objectContaining({
|
||||
avatar: 'https://via.placeholder.com/200',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
expect(stopPingTimeoutJobs).toHaveBeenCalled();
|
||||
expect(GraphQLClient.clearInstance).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('updateAccessTokens updates token fields', async () => {
|
||||
const { updateAccessTokens } = await import('@app/store/modules/config.js');
|
||||
const tokens = {
|
||||
accesstoken: 'new-access-token',
|
||||
refreshtoken: 'new-refresh-token',
|
||||
idtoken: 'new-id-token',
|
||||
};
|
||||
test('loginUser updates state and publishes to pubsub', async () => {
|
||||
const { loginUser } = await import('@app/store/modules/config.js');
|
||||
const userInfo = {
|
||||
email: 'test@example.com',
|
||||
avatar: 'https://via.placeholder.com/200',
|
||||
username: 'testuser',
|
||||
apikey: 'test-api-key',
|
||||
localApiKey: 'test-local-api-key',
|
||||
};
|
||||
|
||||
store.dispatch(updateAccessTokens(tokens));
|
||||
await store.dispatch(loginUser(userInfo));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state).toMatchObject(
|
||||
createConfigMatcher({
|
||||
remote: expect.objectContaining(tokens),
|
||||
})
|
||||
);
|
||||
});
|
||||
expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.OWNER, {
|
||||
owner: {
|
||||
username: userInfo.username,
|
||||
url: '',
|
||||
avatar: userInfo.avatar,
|
||||
},
|
||||
});
|
||||
|
||||
test('updateAllowedOrigins updates extraOrigins', async () => {
|
||||
const { updateAllowedOrigins } = await import('@app/store/modules/config.js');
|
||||
const origins = ['https://test1.com', 'https://test2.com'];
|
||||
|
||||
store.dispatch(updateAllowedOrigins(origins));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.api.extraOrigins).toBe(origins.join(', '));
|
||||
});
|
||||
|
||||
test('setUpnpState updates upnp settings', async () => {
|
||||
const { setUpnpState } = await import('@app/store/modules/config.js');
|
||||
|
||||
store.dispatch(setUpnpState({ enabled: 'yes', status: 'active' }));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.remote.upnpEnabled).toBe('yes');
|
||||
expect(state.connectionStatus.upnpStatus).toBe('active');
|
||||
});
|
||||
|
||||
test('setWanPortToValue updates wanport', async () => {
|
||||
const { setWanPortToValue } = await import('@app/store/modules/config.js');
|
||||
|
||||
store.dispatch(setWanPortToValue(8443));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.remote.wanport).toBe('8443');
|
||||
});
|
||||
|
||||
test('setWanAccess updates wanaccess', async () => {
|
||||
const { setWanAccess } = await import('@app/store/modules/config.js');
|
||||
|
||||
store.dispatch(setWanAccess('yes'));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.remote.wanaccess).toBe('yes');
|
||||
});
|
||||
|
||||
test('addSsoUser adds user to ssoSubIds', async () => {
|
||||
const { addSsoUser } = await import('@app/store/modules/config.js');
|
||||
|
||||
store.dispatch(addSsoUser('user1'));
|
||||
store.dispatch(addSsoUser('user2'));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.remote.ssoSubIds).toBe('user1,user2');
|
||||
});
|
||||
|
||||
test('removeSsoUser removes user from ssoSubIds', async () => {
|
||||
const { addSsoUser, removeSsoUser } = await import('@app/store/modules/config.js');
|
||||
|
||||
store.dispatch(addSsoUser('user1'));
|
||||
store.dispatch(addSsoUser('user2'));
|
||||
store.dispatch(removeSsoUser('user1'));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.remote.ssoSubIds).toBe('user2');
|
||||
});
|
||||
|
||||
test('removeSsoUser with null clears all ssoSubIds', async () => {
|
||||
const { addSsoUser, removeSsoUser } = await import('@app/store/modules/config.js');
|
||||
|
||||
store.dispatch(addSsoUser('user1'));
|
||||
store.dispatch(addSsoUser('user2'));
|
||||
store.dispatch(removeSsoUser(null));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.remote.ssoSubIds).toBe('');
|
||||
});
|
||||
|
||||
test('setLocalApiKey updates localApiKey', async () => {
|
||||
const { setLocalApiKey } = await import('@app/store/modules/config.js');
|
||||
|
||||
store.dispatch(setLocalApiKey('new-local-api-key'));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.remote.localApiKey).toBe('new-local-api-key');
|
||||
});
|
||||
|
||||
test('setLocalApiKey with null clears localApiKey', async () => {
|
||||
const { setLocalApiKey } = await import('@app/store/modules/config.js');
|
||||
|
||||
store.dispatch(setLocalApiKey(null));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.remote.localApiKey).toBe('');
|
||||
});
|
||||
|
||||
test('setGraphqlConnectionStatus updates minigraph status', async () => {
|
||||
store.dispatch(setGraphqlConnectionStatus({ status: MinigraphStatus.CONNECTED, error: null }));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.connectionStatus.minigraph).toBe(MinigraphStatus.CONNECTED);
|
||||
});
|
||||
|
||||
test('setupRemoteAccessThunk.fulfilled updates remote access settings', async () => {
|
||||
const remoteAccessSettings = {
|
||||
accessType: WAN_ACCESS_TYPE.DYNAMIC,
|
||||
forwardType: WAN_FORWARD_TYPE.UPNP,
|
||||
};
|
||||
|
||||
await store.dispatch(setupRemoteAccessThunk(remoteAccessSettings));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.remote).toMatchObject({
|
||||
wanaccess: 'no',
|
||||
dynamicRemoteAccessType: 'UPNP',
|
||||
wanport: '',
|
||||
upnpEnabled: 'yes',
|
||||
const state = store.getState().config;
|
||||
expect(state).toMatchObject(
|
||||
createConfigMatcher({
|
||||
remote: expect.objectContaining(userInfo),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('logoutUser clears state and publishes to pubsub', async () => {
|
||||
const { logoutUser } = await import('@app/store/modules/config.js');
|
||||
|
||||
await store.dispatch(logoutUser({ reason: 'test logout' }));
|
||||
|
||||
expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.SERVERS, { servers: [] });
|
||||
expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.OWNER, {
|
||||
owner: {
|
||||
username: 'root',
|
||||
url: '',
|
||||
avatar: '',
|
||||
},
|
||||
});
|
||||
// expect(stopPingTimeoutJobs).toHaveBeenCalled();
|
||||
// expect(GraphQLClient.clearInstance).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('updateAccessTokens updates token fields', async () => {
|
||||
const { updateAccessTokens } = await import('@app/store/modules/config.js');
|
||||
const tokens = {
|
||||
accesstoken: 'new-access-token',
|
||||
refreshtoken: 'new-refresh-token',
|
||||
idtoken: 'new-id-token',
|
||||
};
|
||||
|
||||
store.dispatch(updateAccessTokens(tokens));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state).toMatchObject(
|
||||
createConfigMatcher({
|
||||
remote: expect.objectContaining(tokens),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('updateAllowedOrigins updates extraOrigins', async () => {
|
||||
const { updateAllowedOrigins } = await import('@app/store/modules/config.js');
|
||||
const origins = ['https://test1.com', 'https://test2.com'];
|
||||
|
||||
store.dispatch(updateAllowedOrigins(origins));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.api.extraOrigins).toBe(origins.join(', '));
|
||||
});
|
||||
|
||||
test('setUpnpState updates upnp settings', async () => {
|
||||
const { setUpnpState } = await import('@app/store/modules/config.js');
|
||||
|
||||
store.dispatch(setUpnpState({ enabled: 'yes', status: 'active' }));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.remote.upnpEnabled).toBe('yes');
|
||||
expect(state.connectionStatus.upnpStatus).toBe('active');
|
||||
});
|
||||
|
||||
test('setWanPortToValue updates wanport', async () => {
|
||||
const { setWanPortToValue } = await import('@app/store/modules/config.js');
|
||||
|
||||
store.dispatch(setWanPortToValue(8443));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.remote.wanport).toBe('8443');
|
||||
});
|
||||
|
||||
test('setWanAccess updates wanaccess', async () => {
|
||||
const { setWanAccess } = await import('@app/store/modules/config.js');
|
||||
|
||||
store.dispatch(setWanAccess('yes'));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.remote.wanaccess).toBe('yes');
|
||||
});
|
||||
|
||||
// test('addSsoUser adds user to ssoSubIds', async () => {
|
||||
// const { addSsoUser } = await import('@app/store/modules/config.js');
|
||||
|
||||
// store.dispatch(addSsoUser('user1'));
|
||||
// store.dispatch(addSsoUser('user2'));
|
||||
|
||||
// const state = store.getState().config;
|
||||
// expect(state.remote.ssoSubIds).toBe('user1,user2');
|
||||
// });
|
||||
|
||||
// test('removeSsoUser removes user from ssoSubIds', async () => {
|
||||
// const { addSsoUser, removeSsoUser } = await import('@app/store/modules/config.js');
|
||||
|
||||
// store.dispatch(addSsoUser('user1'));
|
||||
// store.dispatch(addSsoUser('user2'));
|
||||
// store.dispatch(removeSsoUser('user1'));
|
||||
|
||||
// const state = store.getState().config;
|
||||
// expect(state.remote.ssoSubIds).toBe('user2');
|
||||
// });
|
||||
|
||||
// test('removeSsoUser with null clears all ssoSubIds', async () => {
|
||||
// const { addSsoUser, removeSsoUser } = await import('@app/store/modules/config.js');
|
||||
|
||||
// store.dispatch(addSsoUser('user1'));
|
||||
// store.dispatch(addSsoUser('user2'));
|
||||
// store.dispatch(removeSsoUser(null));
|
||||
|
||||
// const state = store.getState().config;
|
||||
// expect(state.remote.ssoSubIds).toBe('');
|
||||
// });
|
||||
|
||||
test('setLocalApiKey updates localApiKey', async () => {
|
||||
const { setLocalApiKey } = await import('@app/store/modules/config.js');
|
||||
|
||||
store.dispatch(setLocalApiKey('new-local-api-key'));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.remote.localApiKey).toBe('new-local-api-key');
|
||||
});
|
||||
|
||||
test('setLocalApiKey with null clears localApiKey', async () => {
|
||||
const { setLocalApiKey } = await import('@app/store/modules/config.js');
|
||||
|
||||
store.dispatch(setLocalApiKey(null));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.remote.localApiKey).toBe('');
|
||||
});
|
||||
|
||||
// test('setGraphqlConnectionStatus updates minigraph status', async () => {
|
||||
// store.dispatch(setGraphqlConnectionStatus({ status: MinigraphStatus.CONNECTED, error: null }));
|
||||
|
||||
// const state = store.getState().config;
|
||||
// expect(state.connectionStatus.minigraph).toBe(MinigraphStatus.CONNECTED);
|
||||
// });
|
||||
|
||||
// test('setupRemoteAccessThunk.fulfilled updates remote access settings', async () => {
|
||||
// const remoteAccessSettings = {
|
||||
// accessType: WAN_ACCESS_TYPE.DYNAMIC,
|
||||
// forwardType: WAN_FORWARD_TYPE.UPNP,
|
||||
// };
|
||||
|
||||
// await store.dispatch(setupRemoteAccessThunk(remoteAccessSettings));
|
||||
|
||||
// const state = store.getState().config;
|
||||
// expect(state.remote).toMatchObject({
|
||||
// wanaccess: 'no',
|
||||
// dynamicRemoteAccessType: 'UPNP',
|
||||
// wanport: '',
|
||||
// upnpEnabled: 'yes',
|
||||
// });
|
||||
// });
|
||||
});
|
||||
|
||||
@@ -4,32 +4,63 @@ import { store } from '@app/store/index.js';
|
||||
|
||||
test('Returns paths', async () => {
|
||||
const { paths } = store.getState();
|
||||
expect(Object.keys(paths)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"core",
|
||||
"unraid-api-base",
|
||||
"unraid-data",
|
||||
"docker-autostart",
|
||||
"docker-socket",
|
||||
"parity-checks",
|
||||
"htpasswd",
|
||||
"emhttpd-socket",
|
||||
"states",
|
||||
"dynamix-base",
|
||||
"dynamix-config",
|
||||
"myservers-base",
|
||||
"myservers-config",
|
||||
"myservers-config-states",
|
||||
"myservers-env",
|
||||
"myservers-keepalive",
|
||||
"keyfile-base",
|
||||
"machine-id",
|
||||
"log-base",
|
||||
"unraid-log-base",
|
||||
"var-run",
|
||||
"auth-sessions",
|
||||
"auth-keys",
|
||||
"libvirt-pid",
|
||||
]
|
||||
`);
|
||||
expect(Object.keys(paths)).toMatchSnapshot();
|
||||
|
||||
expect(paths).toMatchObject({
|
||||
core: expect.stringContaining('api/src/store/modules'),
|
||||
'unraid-api-base': '/usr/local/unraid-api/',
|
||||
'unraid-data': expect.stringContaining('api/dev/data'),
|
||||
'docker-autostart': '/var/lib/docker/unraid-autostart',
|
||||
'docker-socket': '/var/run/docker.sock',
|
||||
'parity-checks': expect.stringContaining('api/dev/states/parity-checks.log'),
|
||||
htpasswd: '/etc/nginx/htpasswd',
|
||||
'emhttpd-socket': '/var/run/emhttpd.socket',
|
||||
states: expect.stringContaining('api/dev/states'),
|
||||
'dynamix-base': expect.stringContaining('api/dev/dynamix'),
|
||||
'dynamix-config': expect.arrayContaining([
|
||||
expect.stringContaining('api/dev/dynamix/default.cfg'),
|
||||
expect.stringContaining('api/dev/dynamix/dynamix.cfg'),
|
||||
]),
|
||||
'myservers-base': '/boot/config/plugins/dynamix.my.servers/',
|
||||
'myservers-config': expect.stringContaining('api/dev/Unraid.net/myservers.cfg'),
|
||||
'myservers-config-states': expect.stringContaining('api/dev/states/myservers.cfg'),
|
||||
'myservers-env': '/boot/config/plugins/dynamix.my.servers/env',
|
||||
'myservers-keepalive': './dev/Unraid.net/fb_keepalive',
|
||||
'keyfile-base': expect.stringContaining('api/dev/Unraid.net'),
|
||||
'machine-id': expect.stringContaining('api/dev/data/machine-id'),
|
||||
'log-base': '/var/log/unraid-api',
|
||||
'unraid-log-base': '/var/log',
|
||||
'var-run': '/var/run',
|
||||
'auth-sessions': './dev/sessions',
|
||||
'auth-keys': expect.stringContaining('api/dev/keys'),
|
||||
passwd: expect.stringContaining('api/dev/passwd'),
|
||||
'libvirt-pid': '/var/run/libvirt/libvirtd.pid',
|
||||
activationBase: expect.stringContaining('api/dev/activation'),
|
||||
webGuiBase: '/usr/local/emhttp/webGui',
|
||||
identConfig: '/boot/config/ident.cfg',
|
||||
activation: {
|
||||
assets: expect.stringContaining('api/dev/activation/assets'),
|
||||
logo: expect.stringContaining('api/dev/activation/assets/logo.svg'),
|
||||
caseModel: expect.stringContaining('api/dev/activation/assets/case-model.png'),
|
||||
banner: expect.stringContaining('api/dev/activation/assets/banner.png'),
|
||||
},
|
||||
boot: {
|
||||
caseModel: expect.stringContaining('api/dev/dynamix/case-model.png'),
|
||||
},
|
||||
webgui: {
|
||||
imagesBase: '/usr/local/emhttp/webGui/images',
|
||||
logo: {
|
||||
fullPath: '/usr/local/emhttp/webGui/images/UN-logotype-gradient.svg',
|
||||
assetPath: '/webGui/images/UN-logotype-gradient.svg',
|
||||
},
|
||||
caseModel: {
|
||||
fullPath: '/usr/local/emhttp/webGui/images/case-model.png',
|
||||
assetPath: '/webGui/images/case-model.png',
|
||||
},
|
||||
banner: {
|
||||
fullPath: '/usr/local/emhttp/webGui/images/banner.png',
|
||||
assetPath: '/webGui/images/banner.png',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { csvStringToArray, formatDatetime } from '@app/utils.js';
|
||||
import { csvStringToArray, formatDatetime, parsePackageArg } from '@app/utils.js';
|
||||
|
||||
describe('formatDatetime', () => {
|
||||
const testDate = new Date('2024-02-14T12:34:56');
|
||||
@@ -103,3 +103,78 @@ describe('csvStringToArray', () => {
|
||||
expect(csvStringToArray(',one,')).toEqual(['one']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePackageArg', () => {
|
||||
it('parses simple package names without version', () => {
|
||||
expect(parsePackageArg('lodash')).toEqual({ name: 'lodash' });
|
||||
expect(parsePackageArg('express')).toEqual({ name: 'express' });
|
||||
expect(parsePackageArg('react')).toEqual({ name: 'react' });
|
||||
});
|
||||
|
||||
it('parses simple package names with version', () => {
|
||||
expect(parsePackageArg('lodash@4.17.21')).toEqual({ name: 'lodash', version: '4.17.21' });
|
||||
expect(parsePackageArg('express@4.18.2')).toEqual({ name: 'express', version: '4.18.2' });
|
||||
expect(parsePackageArg('react@18.2.0')).toEqual({ name: 'react', version: '18.2.0' });
|
||||
});
|
||||
|
||||
it('parses scoped package names without version', () => {
|
||||
expect(parsePackageArg('@types/node')).toEqual({ name: '@types/node' });
|
||||
expect(parsePackageArg('@angular/core')).toEqual({ name: '@angular/core' });
|
||||
expect(parsePackageArg('@nestjs/common')).toEqual({ name: '@nestjs/common' });
|
||||
});
|
||||
|
||||
it('parses scoped package names with version', () => {
|
||||
expect(parsePackageArg('@types/node@18.15.0')).toEqual({
|
||||
name: '@types/node',
|
||||
version: '18.15.0',
|
||||
});
|
||||
expect(parsePackageArg('@angular/core@15.2.0')).toEqual({
|
||||
name: '@angular/core',
|
||||
version: '15.2.0',
|
||||
});
|
||||
expect(parsePackageArg('@nestjs/common@9.3.12')).toEqual({
|
||||
name: '@nestjs/common',
|
||||
version: '9.3.12',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles version ranges and tags', () => {
|
||||
expect(parsePackageArg('lodash@^4.17.0')).toEqual({ name: 'lodash', version: '^4.17.0' });
|
||||
expect(parsePackageArg('react@~18.2.0')).toEqual({ name: 'react', version: '~18.2.0' });
|
||||
expect(parsePackageArg('express@latest')).toEqual({ name: 'express', version: 'latest' });
|
||||
expect(parsePackageArg('vue@beta')).toEqual({ name: 'vue', version: 'beta' });
|
||||
expect(parsePackageArg('@types/node@next')).toEqual({ name: '@types/node', version: 'next' });
|
||||
});
|
||||
|
||||
it('handles multiple @ symbols correctly', () => {
|
||||
expect(parsePackageArg('package@1.0.0@extra')).toEqual({
|
||||
name: 'package@1.0.0',
|
||||
version: 'extra',
|
||||
});
|
||||
expect(parsePackageArg('@scope/pkg@1.0.0@extra')).toEqual({
|
||||
name: '@scope/pkg@1.0.0',
|
||||
version: 'extra',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores versions that contain forward slashes', () => {
|
||||
expect(parsePackageArg('package@github:user/repo')).toEqual({
|
||||
name: 'package@github:user/repo',
|
||||
});
|
||||
expect(parsePackageArg('@scope/pkg@git+https://github.com/user/repo.git')).toEqual({
|
||||
name: '@scope/pkg@git+https://github.com/user/repo.git',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles edge cases', () => {
|
||||
expect(parsePackageArg('@')).toEqual({ name: '@' });
|
||||
expect(parsePackageArg('@scope')).toEqual({ name: '@scope' });
|
||||
expect(parsePackageArg('package@')).toEqual({ name: 'package@' });
|
||||
expect(parsePackageArg('@scope/pkg@')).toEqual({ name: '@scope/pkg@' });
|
||||
});
|
||||
|
||||
it('handles empty version strings', () => {
|
||||
expect(parsePackageArg('package@')).toEqual({ name: 'package@' });
|
||||
expect(parsePackageArg('@scope/package@')).toEqual({ name: '@scope/package@' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ const getUnraidApiLocation = async () => {
|
||||
};
|
||||
|
||||
try {
|
||||
await import('json-bigint-patch');
|
||||
await CommandFactory.run(CliModule, {
|
||||
cliName: 'unraid-api',
|
||||
logger: LOG_LEVEL === 'TRACE' ? new LogService() : false, // - enable this to see nest initialization issues
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { FileLoadStatus } from '@app/store/types.js';
|
||||
|
||||
/**
|
||||
@@ -6,6 +5,7 @@ import { FileLoadStatus } from '@app/store/types.js';
|
||||
* @returns The current version.
|
||||
*/
|
||||
export const getUnraidVersion = async (): Promise<string> => {
|
||||
const { getters } = await import('@app/store/index.js');
|
||||
const { status, var: emhttpVar } = getters.emhttp();
|
||||
if (status === FileLoadStatus.LOADED) {
|
||||
return emhttpVar.version;
|
||||
|
||||
@@ -79,6 +79,3 @@ export const KEYSERVER_VALIDATION_ENDPOINT = 'https://keys.lime-technology.com/v
|
||||
|
||||
/** Set the max retries for the GraphQL Client */
|
||||
export const MAX_RETRIES_FOR_LINEAR_BACKOFF = 100;
|
||||
|
||||
export const PM2_PATH = join(import.meta.dirname, '../../', 'node_modules', 'pm2', 'bin', 'pm2');
|
||||
export const ECOSYSTEM_PATH = join(import.meta.dirname, '../../', 'ecosystem.config.json');
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { pino } from 'pino';
|
||||
import pretty from 'pino-pretty';
|
||||
|
||||
import { LOG_TYPE } from '@app/environment.js';
|
||||
import { API_VERSION, LOG_LEVEL, LOG_TYPE } from '@app/environment.js';
|
||||
|
||||
export const levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const;
|
||||
|
||||
export type LogLevel = (typeof levels)[number];
|
||||
|
||||
const level =
|
||||
levels[levels.indexOf(process.env.LOG_LEVEL?.toLowerCase() as (typeof levels)[number])] ?? 'info';
|
||||
const level = levels[levels.indexOf(LOG_LEVEL.toLowerCase() as LogLevel)] ?? 'info';
|
||||
|
||||
export const logDestination = pino.destination({
|
||||
sync: true,
|
||||
});
|
||||
export const logDestination = pino.destination();
|
||||
|
||||
const stream =
|
||||
LOG_TYPE === 'pretty'
|
||||
@@ -28,9 +25,30 @@ const stream =
|
||||
export const logger = pino(
|
||||
{
|
||||
level,
|
||||
timestamp: () => `,"time":"${new Date().toISOString()}"`,
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
formatters: {
|
||||
level: (label: string) => ({ level: label }),
|
||||
bindings: (bindings) => ({ ...bindings, apiVersion: API_VERSION }),
|
||||
},
|
||||
redact: {
|
||||
paths: [
|
||||
'*.password',
|
||||
'*.pass',
|
||||
'*.secret',
|
||||
'*.token',
|
||||
'*.key',
|
||||
'*.Password',
|
||||
'*.Pass',
|
||||
'*.Secret',
|
||||
'*.Token',
|
||||
'*.Key',
|
||||
'*.apikey',
|
||||
'*.localApiKey',
|
||||
'*.accesstoken',
|
||||
'*.idtoken',
|
||||
'*.refreshtoken',
|
||||
],
|
||||
censor: '***REDACTED***',
|
||||
},
|
||||
},
|
||||
stream
|
||||
@@ -71,3 +89,19 @@ export const loggers = [
|
||||
remoteQueryLogger,
|
||||
apiLogger,
|
||||
];
|
||||
|
||||
export function sanitizeParams(params: Record<string, any>): Record<string, any> {
|
||||
const SENSITIVE_KEYS = ['password', 'secret', 'token', 'key', 'client_secret'];
|
||||
const mask = (value: any) => (typeof value === 'string' && value.length > 0 ? '***' : value);
|
||||
const sanitized: Record<string, any> = {};
|
||||
for (const k in params) {
|
||||
if (SENSITIVE_KEYS.some((s) => k.toLowerCase().includes(s))) {
|
||||
sanitized[k] = mask(params[k]);
|
||||
} else if (typeof params[k] === 'object' && params[k] !== null && !Array.isArray(params[k])) {
|
||||
sanitized[k] = sanitizeParams(params[k]);
|
||||
} else {
|
||||
sanitized[k] = params[k];
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,13 @@
|
||||
import EventEmitter from 'events';
|
||||
|
||||
import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js';
|
||||
import { PubSub } from 'graphql-subscriptions';
|
||||
|
||||
// Allow subscriptions to have 30 connections
|
||||
const eventEmitter = new EventEmitter();
|
||||
eventEmitter.setMaxListeners(30);
|
||||
|
||||
export enum PUBSUB_CHANNEL {
|
||||
ARRAY = 'ARRAY',
|
||||
DASHBOARD = 'DASHBOARD',
|
||||
DISPLAY = 'DISPLAY',
|
||||
INFO = 'INFO',
|
||||
NOTIFICATION = 'NOTIFICATION',
|
||||
NOTIFICATION_ADDED = 'NOTIFICATION_ADDED',
|
||||
NOTIFICATION_OVERVIEW = 'NOTIFICATION_OVERVIEW',
|
||||
OWNER = 'OWNER',
|
||||
SERVERS = 'SERVERS',
|
||||
VMS = 'VMS',
|
||||
REGISTRATION = 'REGISTRATION',
|
||||
LOG_FILE = 'LOG_FILE',
|
||||
PARITY = 'PARITY',
|
||||
}
|
||||
export { GRAPHQL_PUBSUB_CHANNEL as PUBSUB_CHANNEL };
|
||||
|
||||
export const pubsub = new PubSub({ eventEmitter });
|
||||
|
||||
@@ -28,6 +15,6 @@ export const pubsub = new PubSub({ eventEmitter });
|
||||
* Create a pubsub subscription.
|
||||
* @param channel The pubsub channel to subscribe to.
|
||||
*/
|
||||
export const createSubscription = (channel: PUBSUB_CHANNEL) => {
|
||||
export const createSubscription = (channel: GRAPHQL_PUBSUB_CHANNEL) => {
|
||||
return pubsub.asyncIterableIterator(channel);
|
||||
};
|
||||
|
||||
@@ -23,6 +23,9 @@ interface Display {
|
||||
scale: string;
|
||||
tabs: string;
|
||||
text: string;
|
||||
/**
|
||||
* 'black' or 'white' or 'azure' or 'gray'
|
||||
*/
|
||||
theme: string;
|
||||
total: string;
|
||||
unit: Unit;
|
||||
@@ -30,6 +33,32 @@ interface Display {
|
||||
warning: string;
|
||||
wwn: string;
|
||||
locale: string;
|
||||
/**
|
||||
* hex color (without #)
|
||||
*/
|
||||
headerMetaColor: string;
|
||||
/**
|
||||
* 'yes' or 'no'
|
||||
*/
|
||||
showBannerGradient: string;
|
||||
/**
|
||||
* 'yes' or 'no'
|
||||
*/
|
||||
headerdescription: string;
|
||||
/**
|
||||
* Header Secondary Text Color (without #)
|
||||
*/
|
||||
headermetacolor: string;
|
||||
/**
|
||||
* Header Text Color (without #)
|
||||
*/
|
||||
header: string;
|
||||
/**
|
||||
* Header Background Color (without #)
|
||||
*/
|
||||
background: string;
|
||||
tty: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,41 +1,76 @@
|
||||
import { got } from 'got';
|
||||
import retry from 'p-retry';
|
||||
|
||||
import { AppError } from '@app/core/errors/app-error.js';
|
||||
import { logger } from '@app/core/log.js';
|
||||
import { type LooseObject } from '@app/core/types/index.js';
|
||||
import { DRY_RUN } from '@app/environment.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { appLogger } from '@app/core/log.js';
|
||||
import { LooseObject } from '@app/core/types/global.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { loadSingleStateFile } from '@app/store/modules/emhttp.js';
|
||||
import { StateFileKey } from '@app/store/types.js';
|
||||
|
||||
/**
|
||||
* Run a command with emcmd.
|
||||
*/
|
||||
export const emcmd = async (commands: LooseObject) => {
|
||||
export const emcmd = async (
|
||||
commands: LooseObject,
|
||||
{ waitForToken = false }: { waitForToken?: boolean } = {}
|
||||
) => {
|
||||
const { getters } = await import('@app/store/index.js');
|
||||
const socketPath = getters.paths()['emhttpd-socket'];
|
||||
const { csrfToken } = getters.emhttp().var;
|
||||
|
||||
const url = `http://unix:${socketPath}:/update.htm`;
|
||||
const options = {
|
||||
qs: {
|
||||
...commands,
|
||||
csrf_token: csrfToken,
|
||||
},
|
||||
};
|
||||
|
||||
if (DRY_RUN) {
|
||||
logger.debug(url, options);
|
||||
|
||||
// Ensure we only log on dry-run
|
||||
return;
|
||||
if (!socketPath) {
|
||||
appLogger.error('No emhttpd socket path found');
|
||||
throw new AppError('No emhttpd socket path found');
|
||||
}
|
||||
return got
|
||||
.get(url, {
|
||||
enableUnixSockets: true,
|
||||
searchParams: { ...commands, csrf_token: csrfToken },
|
||||
})
|
||||
.catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw new AppError('emhttpd socket unavailable.');
|
||||
|
||||
let { csrfToken } = getters.emhttp().var;
|
||||
|
||||
if (!csrfToken && waitForToken) {
|
||||
csrfToken = await retry(
|
||||
async (retries) => {
|
||||
if (retries > 1) {
|
||||
appLogger.info('Waiting for CSRF token...');
|
||||
}
|
||||
const loadedState = await store.dispatch(loadSingleStateFile(StateFileKey.var)).unwrap();
|
||||
|
||||
let token: string | undefined;
|
||||
if (loadedState && 'var' in loadedState) {
|
||||
token = loadedState.var.csrfToken;
|
||||
}
|
||||
if (!token) {
|
||||
throw new Error('CSRF token not found yet');
|
||||
}
|
||||
return token;
|
||||
},
|
||||
{
|
||||
minTimeout: 5000,
|
||||
maxTimeout: 10000,
|
||||
retries: 10,
|
||||
}
|
||||
throw error;
|
||||
).catch((error) => {
|
||||
appLogger.error('Failed to load CSRF token after multiple retries', error);
|
||||
throw new AppError('Failed to load CSRF token after multiple retries');
|
||||
});
|
||||
}
|
||||
|
||||
appLogger.debug(`Executing emcmd with commands: ${JSON.stringify(commands)}`);
|
||||
|
||||
try {
|
||||
const paramsObj = { ...commands, csrf_token: csrfToken };
|
||||
const params = new URLSearchParams(paramsObj);
|
||||
const response = await got.get(`http://unix:${socketPath}:/update.htm`, {
|
||||
enableUnixSockets: true,
|
||||
searchParams: params,
|
||||
});
|
||||
|
||||
appLogger.debug('emcmd executed successfully');
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
appLogger.error('emhttpd socket unavailable.', error);
|
||||
throw new Error('emhttpd socket unavailable.');
|
||||
}
|
||||
appLogger.error(`emcmd execution failed: ${error.message}`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -124,7 +124,15 @@ export const parseConfig = <T extends Record<string, any>>(
|
||||
throw new AppError('Invalid Parameters Passed to ParseConfig');
|
||||
}
|
||||
|
||||
const data: Record<string, any> = parseIni(fileContents);
|
||||
let data: Record<string, any>;
|
||||
try {
|
||||
data = parseIni(fileContents);
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
`Failed to parse config file: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Remove quotes around keys
|
||||
const dataWithoutQuoteKeys = Object.fromEntries(
|
||||
Object.entries(data).map(([key, value]) => [key.replace(/^"(.+(?="$))"$/, '$1'), value])
|
||||
|
||||
21
api/src/core/utils/validation/is-gui-mode.ts
Normal file
21
api/src/core/utils/validation/is-gui-mode.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { execa } from 'execa';
|
||||
|
||||
import { internalLogger } from '@app/core/log.js';
|
||||
|
||||
/**
|
||||
* Check if Unraid is in GUI mode by looking for the slim process.
|
||||
* @returns true if Unraid is in GUI mode, false otherwise.
|
||||
*/
|
||||
const isGuiMode = async (): Promise<boolean> => {
|
||||
try {
|
||||
// Use pgrep to check if slim process is running
|
||||
const { exitCode } = await execa('pgrep', ['slim'], { reject: false });
|
||||
// exitCode 0 means process was found, 1 means not found
|
||||
return exitCode === 0;
|
||||
} catch (error) {
|
||||
internalLogger.error('Error checking GUI mode: %s', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export default isGuiMode;
|
||||
@@ -9,6 +9,8 @@ const env =
|
||||
override: true,
|
||||
})
|
||||
: config({
|
||||
debug: false,
|
||||
quiet: true,
|
||||
path: '/usr/local/unraid-api/.env',
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Defines environment & configuration constants.
|
||||
// Non-function exports from this module are loaded into the NestJS Config at runtime.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
@@ -78,7 +81,6 @@ export const ENVIRONMENT = process.env.ENVIRONMENT
|
||||
: 'production';
|
||||
export const GRAPHQL_INTROSPECTION = Boolean(INTROSPECTION ?? DEBUG ?? ENVIRONMENT !== 'production');
|
||||
export const PORT = process.env.PORT ?? '/var/run/unraid-api.sock';
|
||||
export const DRY_RUN = process.env.DRY_RUN === 'true';
|
||||
export const BYPASS_PERMISSION_CHECKS = process.env.BYPASS_PERMISSION_CHECKS === 'true';
|
||||
export const BYPASS_CORS_CHECKS = process.env.BYPASS_CORS_CHECKS === 'true';
|
||||
export const LOG_CORS = process.env.LOG_CORS === 'true';
|
||||
@@ -95,4 +97,9 @@ export const MOTHERSHIP_GRAPHQL_LINK = process.env.MOTHERSHIP_GRAPHQL_LINK
|
||||
: 'https://mothership.unraid.net/ws';
|
||||
|
||||
export const PM2_HOME = process.env.PM2_HOME ?? join(homedir(), '.pm2');
|
||||
export const PATHS_CONFIG_MODULES = process.env.PATHS_CONFIG_MODULES!;
|
||||
export const PM2_PATH = join(import.meta.dirname, '../../', 'node_modules', 'pm2', 'bin', 'pm2');
|
||||
export const ECOSYSTEM_PATH = join(import.meta.dirname, '../../', 'ecosystem.config.json');
|
||||
export const LOGS_DIR = process.env.LOGS_DIR ?? '/var/log/unraid-api';
|
||||
|
||||
export const PATHS_CONFIG_MODULES =
|
||||
process.env.PATHS_CONFIG_MODULES ?? '/boot/config/plugins/dynamix.my.servers/configs';
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { ASTNode } from 'graphql';
|
||||
import { GraphQLScalarType } from 'graphql';
|
||||
import { Kind } from 'graphql/language/index.js';
|
||||
|
||||
const MAX_LONG = Number.MAX_SAFE_INTEGER;
|
||||
const MIN_LONG = Number.MIN_SAFE_INTEGER;
|
||||
|
||||
const coerceLong = (value) => {
|
||||
if (value === '')
|
||||
throw new TypeError('Long cannot represent non 52-bit signed integer value: (empty string)');
|
||||
const num = Number(value);
|
||||
if (num == num && num <= MAX_LONG && num >= MIN_LONG) {
|
||||
if (num < 0) {
|
||||
return Math.ceil(num);
|
||||
}
|
||||
return Math.floor(num);
|
||||
}
|
||||
throw new TypeError('Long cannot represent non 52-bit signed integer value: ' + String(value));
|
||||
};
|
||||
|
||||
const parseLiteral = (ast: ASTNode) => {
|
||||
if (ast.kind === Kind.INT) {
|
||||
const num = parseInt(ast.value, 10);
|
||||
if (num <= MAX_LONG && num >= MIN_LONG) return num;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const GraphQLLong = new GraphQLScalarType({
|
||||
name: 'Long',
|
||||
description: 'The `Long` scalar type represents 52-bit integers',
|
||||
serialize: coerceLong,
|
||||
parseValue: coerceLong,
|
||||
parseLiteral: parseLiteral,
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import { logger } from '@app/core/log.js';
|
||||
import { type ApiKeyResponse } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js';
|
||||
|
||||
export const checkApi = async (): Promise<ApiKeyResponse> => {
|
||||
logger.trace('Cloud endpoint: Checking API');
|
||||
return { valid: true };
|
||||
};
|
||||
@@ -1,104 +0,0 @@
|
||||
import { got } from 'got';
|
||||
|
||||
import { FIVE_DAYS_SECS, ONE_DAY_SECS } from '@app/consts.js';
|
||||
import { logger } from '@app/core/log.js';
|
||||
import { API_VERSION, MOTHERSHIP_GRAPHQL_LINK } from '@app/environment.js';
|
||||
import { checkDNS } from '@app/graphql/resolvers/query/cloud/check-dns.js';
|
||||
import { checkMothershipAuthentication } from '@app/graphql/resolvers/query/cloud/check-mothership-authentication.js';
|
||||
import { getCloudCache, getDnsCache } from '@app/store/getters/index.js';
|
||||
import { getters, store } from '@app/store/index.js';
|
||||
import { setCloudCheck, setDNSCheck } from '@app/store/modules/cache.js';
|
||||
import { CloudResponse, MinigraphStatus } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js';
|
||||
|
||||
const mothershipBaseUrl = new URL(MOTHERSHIP_GRAPHQL_LINK).origin;
|
||||
|
||||
const createGotOptions = (apiVersion: string, apiKey: string) => ({
|
||||
timeout: {
|
||||
request: 5_000,
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'x-unraid-api-version': apiVersion,
|
||||
'x-api-key': apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* This is mainly testing the user's network config
|
||||
* If they cannot resolve this they may have it blocked or have a routing issue
|
||||
*/
|
||||
const checkCanReachMothership = async (apiVersion: string, apiKey: string): Promise<void> => {
|
||||
const mothershipCanBeResolved = await got
|
||||
.head(mothershipBaseUrl, createGotOptions(apiVersion, apiKey))
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!mothershipCanBeResolved) throw new Error(`Unable to connect to ${mothershipBaseUrl}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Run a more performant cloud check with permanent DNS checking
|
||||
*/
|
||||
const fastCloudCheck = async (): Promise<CloudResponse> => {
|
||||
const result = { status: 'ok', error: null, ip: 'FAST_CHECK_NO_IP_FOUND' };
|
||||
|
||||
const cloudIp = getDnsCache()?.cloudIp ?? null;
|
||||
if (cloudIp) {
|
||||
result.ip = cloudIp;
|
||||
} else {
|
||||
try {
|
||||
result.ip = (await checkDNS()).cloudIp;
|
||||
logger.debug('DNS_CHECK_RESULT', await checkDNS());
|
||||
store.dispatch(setDNSCheck({ cloudIp: result.ip, ttl: FIVE_DAYS_SECS, error: null }));
|
||||
} catch (error: unknown) {
|
||||
logger.warn('Failed to fetch DNS, but Minigraph is connected - continuing');
|
||||
result.ip = `ERROR: ${error instanceof Error ? error.message : 'Unknown Error'}`;
|
||||
// Don't set an error since we're actually connected to the cloud
|
||||
store.dispatch(setDNSCheck({ cloudIp: result.ip, ttl: ONE_DAY_SECS, error: null }));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const checkCloud = async (): Promise<CloudResponse> => {
|
||||
logger.trace('Cloud endpoint: Checking mothership');
|
||||
|
||||
try {
|
||||
const config = getters.config();
|
||||
const apiVersion = API_VERSION;
|
||||
const apiKey = config.remote.apikey;
|
||||
const graphqlStatus = getters.minigraph().status;
|
||||
const result = { status: 'ok', error: null, ip: 'NO_IP_FOUND' };
|
||||
|
||||
// If minigraph is connected, skip the follow cloud checks
|
||||
if (graphqlStatus === MinigraphStatus.CONNECTED) {
|
||||
return await fastCloudCheck();
|
||||
}
|
||||
|
||||
// Check GraphQL Conneciton State, if it's broken, run these checks
|
||||
if (!apiKey) throw new Error('API key is missing');
|
||||
|
||||
const oldCheckResult = getCloudCache();
|
||||
if (oldCheckResult) {
|
||||
logger.trace('Using cached result for cloud check', oldCheckResult);
|
||||
return oldCheckResult;
|
||||
}
|
||||
|
||||
// Check DNS
|
||||
result.ip = (await checkDNS()).cloudIp;
|
||||
// Check if we can reach mothership
|
||||
await checkCanReachMothership(apiVersion, apiKey);
|
||||
|
||||
// Check auth, rate limiting, etc.
|
||||
await checkMothershipAuthentication(apiVersion, apiKey);
|
||||
|
||||
// Cache for 10 minutes
|
||||
store.dispatch(setCloudCheck(result));
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof Error)) throw new Error(`Unknown Error "${error as string}"`);
|
||||
return { status: 'error', error: error.message };
|
||||
}
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
import { lookup as lookupDNS, resolve as resolveDNS } from 'dns';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import ip from 'ip';
|
||||
|
||||
import { MOTHERSHIP_GRAPHQL_LINK } from '@app/environment.js';
|
||||
import { getDnsCache } from '@app/store/getters/index.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { setDNSCheck } from '@app/store/modules/cache.js';
|
||||
|
||||
const msHostname = new URL(MOTHERSHIP_GRAPHQL_LINK).host;
|
||||
|
||||
/**
|
||||
* Check if the local and network resolvers are able to see mothership
|
||||
*
|
||||
* See: https://nodejs.org/docs/latest/api/dns.html#dns_implementation_considerations
|
||||
*/
|
||||
export const checkDNS = async (hostname = msHostname): Promise<{ cloudIp: string }> => {
|
||||
const dnsCachedResuslt = getDnsCache();
|
||||
if (dnsCachedResuslt) {
|
||||
if (dnsCachedResuslt.cloudIp) {
|
||||
return { cloudIp: dnsCachedResuslt.cloudIp };
|
||||
}
|
||||
|
||||
if (dnsCachedResuslt.error) {
|
||||
throw dnsCachedResuslt.error;
|
||||
}
|
||||
}
|
||||
|
||||
let local: string | null = null;
|
||||
let network: string | null = null;
|
||||
try {
|
||||
// Check the local resolver like "ping" does
|
||||
// Check the DNS server the server has set - does a DNS query on the network
|
||||
const [localRes, networkRes] = await Promise.all([
|
||||
promisify(lookupDNS)(hostname).then(({ address }) => address),
|
||||
promisify(resolveDNS)(hostname).then(([address]) => address),
|
||||
]);
|
||||
local = localRes;
|
||||
network = networkRes;
|
||||
// The user's server and the DNS server they're using are returning different results
|
||||
if (!local.includes(network))
|
||||
throw new Error(
|
||||
`Local and network resolvers showing different IP for "${hostname}". [local="${
|
||||
local ?? 'NOT FOUND'
|
||||
}"] [network="${network ?? 'NOT FOUND'}"]`
|
||||
);
|
||||
|
||||
// The user likely has a PI-hole or something similar running.
|
||||
if (ip.isPrivate(local))
|
||||
throw new Error(
|
||||
`"${hostname}" is being resolved to a private IP. [IP=${local ?? 'NOT FOUND'}]`
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
store.dispatch(setDNSCheck({ cloudIp: null, error }));
|
||||
}
|
||||
|
||||
if (typeof local === 'string' || typeof network === 'string') {
|
||||
const validIp: string = local ?? network ?? '';
|
||||
store.dispatch(setDNSCheck({ cloudIp: validIp, error: null }));
|
||||
|
||||
return { cloudIp: validIp };
|
||||
}
|
||||
|
||||
return { cloudIp: '' };
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import { logger } from '@app/core/log.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { MinigraphqlResponse } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js';
|
||||
|
||||
export const checkMinigraphql = (): MinigraphqlResponse => {
|
||||
logger.trace('Cloud endpoint: Checking mini-graphql');
|
||||
// Do we have a connection to mothership?
|
||||
const { status, error, timeout, timeoutStart } = getters.minigraph();
|
||||
|
||||
const timeoutRemaining = timeout && timeoutStart ? timeout - (Date.now() - timeoutStart) : null;
|
||||
|
||||
return { status, error, timeout: timeoutRemaining };
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
import { got, HTTPError, TimeoutError } from 'got';
|
||||
|
||||
import { logger } from '@app/core/log.js';
|
||||
import { MOTHERSHIP_GRAPHQL_LINK } from '@app/environment.js';
|
||||
|
||||
const createGotOptions = (apiVersion: string, apiKey: string) => ({
|
||||
timeout: {
|
||||
request: 5_000,
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'x-unraid-api-version': apiVersion,
|
||||
'x-api-key': apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
const isHttpError = (error: unknown): error is HTTPError => error instanceof HTTPError;
|
||||
|
||||
// Check if we're rate limited, etc.
|
||||
export const checkMothershipAuthentication = async (apiVersion: string, apiKey: string) => {
|
||||
const msURL = new URL(MOTHERSHIP_GRAPHQL_LINK);
|
||||
const url = `https://${msURL.hostname}${msURL.pathname}`;
|
||||
|
||||
try {
|
||||
const options = createGotOptions(apiVersion, apiKey);
|
||||
|
||||
// This will throw if there is a non 2XX/3XX code
|
||||
await got.head(url, options);
|
||||
} catch (error: unknown) {
|
||||
// HTTP errors
|
||||
if (isHttpError(error)) {
|
||||
switch (error.response.statusCode) {
|
||||
case 429: {
|
||||
const retryAfter = error.response.headers['retry-after'];
|
||||
throw new Error(
|
||||
retryAfter
|
||||
? `${url} is rate limited for another ${retryAfter} seconds`
|
||||
: `${url} is rate limited`
|
||||
);
|
||||
}
|
||||
|
||||
case 401:
|
||||
throw new Error('Invalid credentials');
|
||||
default:
|
||||
throw new Error(
|
||||
`Failed to connect to ${url} with a "${error.response.statusCode}" HTTP error.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout error
|
||||
if (error instanceof TimeoutError) throw new Error(`Timed-out while connecting to "${url}"`);
|
||||
|
||||
// Unknown error
|
||||
logger.trace('Unknown Error', error);
|
||||
// @TODO: Add in the cause when we move to a newer node version
|
||||
// throw new Error('Unknown Error', { cause: error as Error });
|
||||
throw new Error('Unknown Error');
|
||||
}
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
export type Cloud = {
|
||||
error: string | null;
|
||||
apiKey: { valid: true; error: null } | { valid: false; error: string };
|
||||
minigraphql: {
|
||||
status: 'connected' | 'disconnected';
|
||||
};
|
||||
cloud: { status: 'ok'; error: null; ip: string } | { status: 'error'; error: string };
|
||||
allowedOrigins: string[];
|
||||
};
|
||||
|
||||
export const createResponse = (cloud: Omit<Cloud, 'error'>): Cloud => ({
|
||||
...cloud,
|
||||
error: cloud.apiKey.error ?? cloud.cloud.error,
|
||||
});
|
||||
@@ -1,423 +0,0 @@
|
||||
import { access } from 'fs/promises';
|
||||
|
||||
import toBytes from 'bytes';
|
||||
import { execa, execaCommandSync } from 'execa';
|
||||
import { isSymlink } from 'path-type';
|
||||
import { cpu, cpuFlags, mem, memLayout, osInfo, versions } from 'systeminformation';
|
||||
|
||||
import type { PciDevice } from '@app/core/types/index.js';
|
||||
import { bootTimestamp } from '@app/common/dashboard/boot-timestamp.js';
|
||||
import { getUnraidVersion } from '@app/common/dashboard/get-unraid-version.js';
|
||||
import { AppError } from '@app/core/errors/app-error.js';
|
||||
import { type DynamixConfig } from '@app/core/types/ini.js';
|
||||
import { toBoolean } from '@app/core/utils/casting.js';
|
||||
import { docker } from '@app/core/utils/clients/docker.js';
|
||||
import { cleanStdout } from '@app/core/utils/misc/clean-stdout.js';
|
||||
import { loadState } from '@app/core/utils/misc/load-state.js';
|
||||
import { sanitizeProduct } from '@app/core/utils/vms/domain/sanitize-product.js';
|
||||
import { sanitizeVendor } from '@app/core/utils/vms/domain/sanitize-vendor.js';
|
||||
import { vmRegExps } from '@app/core/utils/vms/domain/vm-regexps.js';
|
||||
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 {
|
||||
Devices,
|
||||
Display,
|
||||
Gpu,
|
||||
InfoApps,
|
||||
InfoCpu,
|
||||
InfoMemory,
|
||||
Os as InfoOs,
|
||||
MemoryLayout,
|
||||
Temperature,
|
||||
Theme,
|
||||
Versions,
|
||||
} from '@app/unraid-api/graph/resolvers/info/info.model.js';
|
||||
|
||||
export const generateApps = async (): Promise<InfoApps> => {
|
||||
const installed = await docker
|
||||
.listContainers({ all: true })
|
||||
.catch(() => [])
|
||||
.then((containers) => containers.length);
|
||||
const started = await docker
|
||||
.listContainers()
|
||||
.catch(() => [])
|
||||
.then((containers) => containers.length);
|
||||
return { id: 'info/apps', installed, started };
|
||||
};
|
||||
|
||||
export const generateOs = async (): Promise<InfoOs> => {
|
||||
const os = await osInfo();
|
||||
|
||||
return {
|
||||
id: 'info/os',
|
||||
...os,
|
||||
hostname: getters.emhttp().var.name,
|
||||
uptime: bootTimestamp.toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
export const generateCpu = async (): 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),
|
||||
// @TODO Find out what these should be if they're not defined
|
||||
speedmin: speedMin || -1,
|
||||
speedmax: speedMax || -1,
|
||||
};
|
||||
};
|
||||
|
||||
export const generateDisplay = async (): Promise<Display> => {
|
||||
const filePaths = getters.paths()['dynamix-config'];
|
||||
|
||||
const state = filePaths.reduce<Partial<DynamixConfig>>(
|
||||
(acc, filePath) => {
|
||||
const state = loadState<DynamixConfig>(filePath);
|
||||
return state ? { ...acc, ...state } : acc;
|
||||
},
|
||||
{
|
||||
id: 'dynamix-config/display',
|
||||
}
|
||||
);
|
||||
|
||||
if (!state.display) {
|
||||
return {
|
||||
id: 'dynamix-config/display',
|
||||
};
|
||||
}
|
||||
const { theme, unit, ...display } = state.display;
|
||||
return {
|
||||
id: 'dynamix-config/display',
|
||||
...display,
|
||||
theme: theme as Theme,
|
||||
unit: unit as Temperature,
|
||||
scale: toBoolean(display.scale),
|
||||
tabs: toBoolean(display.tabs),
|
||||
resize: toBoolean(display.resize),
|
||||
wwn: toBoolean(display.wwn),
|
||||
total: toBoolean(display.total),
|
||||
usage: toBoolean(display.usage),
|
||||
text: toBoolean(display.text),
|
||||
warning: Number.parseInt(display.warning, 10),
|
||||
critical: Number.parseInt(display.critical, 10),
|
||||
hot: Number.parseInt(display.hot, 10),
|
||||
max: Number.parseInt(display.max, 10),
|
||||
locale: display.locale || 'en_US',
|
||||
};
|
||||
};
|
||||
|
||||
export const generateVersions = async (): Promise<Versions> => {
|
||||
const unraid = await getUnraidVersion();
|
||||
const softwareVersions = await versions();
|
||||
|
||||
return {
|
||||
id: 'info/versions',
|
||||
unraid,
|
||||
...softwareVersions,
|
||||
};
|
||||
};
|
||||
|
||||
export const generateMemory = async (): Promise<InfoMemory> => {
|
||||
const layout = await memLayout()
|
||||
.then((dims) => dims.map((dim) => dim as MemoryLayout))
|
||||
.catch(() => []);
|
||||
const info = await mem();
|
||||
let max = info.total;
|
||||
|
||||
// Max memory
|
||||
try {
|
||||
const memoryInfo = await execa('dmidecode', ['-t', 'memory'])
|
||||
.then(cleanStdout)
|
||||
.catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw new AppError('The dmidecode cli utility is missing.');
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
const lines = memoryInfo.split('\n');
|
||||
const header = lines.find((line) => line.startsWith('Physical Memory Array'));
|
||||
if (header) {
|
||||
const start = lines.indexOf(header);
|
||||
const nextHeaders = lines.slice(start, -1).find((line) => line.startsWith('Handle '));
|
||||
|
||||
if (nextHeaders) {
|
||||
const end = lines.indexOf(nextHeaders);
|
||||
const fields = lines.slice(start, end);
|
||||
|
||||
max =
|
||||
toBytes(
|
||||
fields
|
||||
?.find((line) => line.trim().startsWith('Maximum Capacity'))
|
||||
?.trim()
|
||||
?.split(': ')[1] ?? '0'
|
||||
) ?? 0;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors here
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'info/memory',
|
||||
layout,
|
||||
max,
|
||||
...info,
|
||||
};
|
||||
};
|
||||
|
||||
export const generateDevices = async (): Promise<Devices> => {
|
||||
/**
|
||||
* Set device class to device.
|
||||
* @param device The device to modify.
|
||||
* @returns The same device passed in but with the class modified.
|
||||
*/
|
||||
const addDeviceClass = (device: Readonly<PciDevice>): PciDevice => {
|
||||
const modifiedDevice: PciDevice = {
|
||||
...device,
|
||||
class: 'other',
|
||||
};
|
||||
|
||||
// GPU
|
||||
if (vmRegExps.allowedGpuClassId.test(device.typeid)) {
|
||||
modifiedDevice.class = 'vga';
|
||||
// Specialized product name cleanup for GPU
|
||||
// GF116 [GeForce GTX 550 Ti] --> GeForce GTX 550 Ti
|
||||
const regex = new RegExp(/.+\[(?<gpuName>.+)]/);
|
||||
const productName = regex.exec(device.productname)?.groups?.gpuName;
|
||||
|
||||
if (productName) {
|
||||
modifiedDevice.productname = productName;
|
||||
}
|
||||
|
||||
return modifiedDevice;
|
||||
// Audio
|
||||
}
|
||||
|
||||
if (vmRegExps.allowedAudioClassId.test(device.typeid)) {
|
||||
modifiedDevice.class = 'audio';
|
||||
|
||||
return modifiedDevice;
|
||||
}
|
||||
|
||||
return modifiedDevice;
|
||||
};
|
||||
|
||||
/**
|
||||
* System PCI devices.
|
||||
*/
|
||||
const systemPciDevices = async (): Promise<PciDevice[]> => {
|
||||
const devices = await getPciDevices();
|
||||
const basePath = '/sys/bus/pci/devices/0000:';
|
||||
|
||||
// Remove devices with no IOMMU support
|
||||
const filteredDevices = await Promise.all(
|
||||
devices.map(async (device: Readonly<PciDevice>) => {
|
||||
const exists = await access(`${basePath}${device.id}/iommu_group/`)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
return exists ? device : null;
|
||||
})
|
||||
).then((devices) => devices.filter((device) => device !== null));
|
||||
|
||||
/**
|
||||
* Run device cleanup
|
||||
*
|
||||
* Tasks:
|
||||
* - Mark disallowed devices
|
||||
* - Add class
|
||||
* - Add whether kernel-bound driver exists
|
||||
* - Cleanup device vendor/product names
|
||||
*/
|
||||
const processedDevices = await filterDevices(filteredDevices).then(async (devices) =>
|
||||
Promise.all(
|
||||
devices
|
||||
.map((device) => addDeviceClass(device as PciDevice))
|
||||
.map(async (device) => {
|
||||
// Attempt to get the current kernel-bound driver for this pci device
|
||||
await isSymlink(`${basePath}${device.id}/driver`).then((symlink) => {
|
||||
if (symlink) {
|
||||
// $strLink = @readlink('/sys/bus/pci/devices/0000:'.$arrMatch['id']. '/driver');
|
||||
// if (!empty($strLink)) {
|
||||
// $strDriver = basename($strLink);
|
||||
// }
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up the vendor and product name
|
||||
device.vendorname = sanitizeVendor(device.vendorname);
|
||||
device.productname = sanitizeProduct(device.productname);
|
||||
|
||||
return device;
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return processedDevices;
|
||||
};
|
||||
|
||||
/**
|
||||
* System GPU Devices
|
||||
*
|
||||
* @name systemGPUDevices
|
||||
* @ignore
|
||||
* @private
|
||||
*/
|
||||
const systemGPUDevices: Promise<Gpu[]> = systemPciDevices()
|
||||
.then((devices) => {
|
||||
return devices
|
||||
.filter((device) => device.class === 'vga' && !device.allowed)
|
||||
.map((entry) => {
|
||||
const gpu: Gpu = {
|
||||
blacklisted: entry.allowed,
|
||||
class: entry.class,
|
||||
id: entry.id,
|
||||
productid: entry.product,
|
||||
typeid: entry.typeid,
|
||||
type: entry.manufacturer,
|
||||
vendorname: entry.vendorname,
|
||||
};
|
||||
return gpu;
|
||||
});
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
/**
|
||||
* System usb devices.
|
||||
* @returns Array of USB devices.
|
||||
*/
|
||||
const getSystemUSBDevices = async () => {
|
||||
try {
|
||||
// Get a list of all usb hubs so we can filter the allowed/disallowed
|
||||
const usbHubs = await execa('cat /sys/bus/usb/drivers/hub/*/modalias', { shell: true })
|
||||
.then(({ stdout }) =>
|
||||
stdout.split('\n').map((line) => {
|
||||
const [, id] = line.match(/usb:v(\w{9})/) ?? [];
|
||||
return id.replace('p', ':');
|
||||
})
|
||||
)
|
||||
.catch(() => [] as string[]);
|
||||
|
||||
const emhttp = getters.emhttp();
|
||||
|
||||
// Remove boot drive
|
||||
const filterBootDrive = (device: Readonly<PciDevice>): boolean =>
|
||||
emhttp.var.flashGuid !== device.guid;
|
||||
|
||||
// Remove usb hubs
|
||||
const filterUsbHubs = (device: Readonly<PciDevice>): boolean => !usbHubs.includes(device.id);
|
||||
|
||||
// Clean up the name
|
||||
const sanitizeVendorName = (device: Readonly<PciDevice>) => {
|
||||
const vendorname = sanitizeVendor(device.vendorname || '');
|
||||
return {
|
||||
...device,
|
||||
vendorname,
|
||||
};
|
||||
};
|
||||
|
||||
const parseDeviceLine = (line: Readonly<string>): { value: string; string: string } => {
|
||||
const emptyLine = { value: '', string: '' };
|
||||
|
||||
// If the line is blank return nothing
|
||||
if (!line) {
|
||||
return emptyLine;
|
||||
}
|
||||
|
||||
// Parse the line
|
||||
const [, _] = line.split(/[ \t]{2,}/).filter(Boolean);
|
||||
|
||||
const match = _.match(/^(\S+)\s(.*)/)?.slice(1);
|
||||
|
||||
// If there's no match return nothing
|
||||
if (!match) {
|
||||
return emptyLine;
|
||||
}
|
||||
|
||||
return {
|
||||
value: match[0],
|
||||
string: match[1],
|
||||
};
|
||||
};
|
||||
|
||||
// Add extra fields to device
|
||||
const parseDevice = (device: Readonly<PciDevice>) => {
|
||||
const modifiedDevice: PciDevice = {
|
||||
...device,
|
||||
};
|
||||
const info = execaCommandSync(`lsusb -d ${device.id} -v`).stdout.split('\n');
|
||||
const deviceName = device.name.trim();
|
||||
const iSerial = parseDeviceLine(info.filter((line) => line.includes('iSerial'))[0]);
|
||||
const iProduct = parseDeviceLine(info.filter((line) => line.includes('iProduct'))[0]);
|
||||
const iManufacturer = parseDeviceLine(
|
||||
info.filter((line) => line.includes('iManufacturer'))[0]
|
||||
);
|
||||
const idProduct = parseDeviceLine(info.filter((line) => line.includes('idProduct'))[0]);
|
||||
const idVendor = parseDeviceLine(info.filter((line) => line.includes('idVendor'))[0]);
|
||||
const serial = `${iSerial.string.slice(8).slice(0, 4)}-${iSerial.string
|
||||
.slice(8)
|
||||
.slice(4)}`;
|
||||
const guid = `${idVendor.value.slice(2)}-${idProduct.value.slice(2)}-${serial}`;
|
||||
|
||||
modifiedDevice.serial = iSerial.string;
|
||||
modifiedDevice.product = iProduct.string;
|
||||
modifiedDevice.manufacturer = iManufacturer.string;
|
||||
modifiedDevice.guid = guid;
|
||||
|
||||
// Set name if missing
|
||||
if (deviceName === '') {
|
||||
modifiedDevice.name = `${iProduct.string} ${iManufacturer.string}`.trim();
|
||||
}
|
||||
|
||||
// Name still blank? Replace using fallback default
|
||||
if (deviceName === '') {
|
||||
modifiedDevice.name = '[unnamed device]';
|
||||
}
|
||||
|
||||
// Ensure name is trimmed
|
||||
modifiedDevice.name = device.name.trim();
|
||||
|
||||
return modifiedDevice;
|
||||
};
|
||||
|
||||
const parseUsbDevices = (stdout: string) =>
|
||||
stdout.split('\n').map((line) => {
|
||||
const regex = new RegExp(/^.+: ID (?<id>\S+)(?<name>.*)$/);
|
||||
const result = regex.exec(line);
|
||||
return result?.groups as unknown as PciDevice;
|
||||
}) ?? [];
|
||||
|
||||
// Get all usb devices
|
||||
const usbDevices = await execa('lsusb')
|
||||
.then(async ({ stdout }) =>
|
||||
parseUsbDevices(stdout)
|
||||
.map(parseDevice)
|
||||
.filter(filterBootDrive)
|
||||
.filter(filterUsbHubs)
|
||||
.map(sanitizeVendorName)
|
||||
)
|
||||
.catch(() => []);
|
||||
|
||||
return usbDevices;
|
||||
} catch (error: unknown) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
id: 'info/devices',
|
||||
// Scsi: await scsiDevices,
|
||||
gpu: await systemGPUDevices,
|
||||
pci: await systemPciDevices(),
|
||||
usb: await getSystemUSBDevices(),
|
||||
};
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
import { AccessUrl, URL_TYPE } from '@unraid/shared/network.model.js';
|
||||
|
||||
import type { RootState } from '@app/store/index.js';
|
||||
import { logger } from '@app/core/log.js';
|
||||
import { type Nginx } from '@app/core/types/states/nginx.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { AccessUrl, URL_TYPE } from '@app/unraid-api/graph/resolvers/connect/connect.model.js';
|
||||
|
||||
interface UrlForFieldInput {
|
||||
url: string;
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import type { RemoteGraphQlEventFragmentFragment } from '@app/graphql/generated/client/graphql.js';
|
||||
import { remoteQueryLogger } from '@app/core/log.js';
|
||||
import { getApiApolloClient } from '@app/graphql/client/api/get-api-client.js';
|
||||
import { RemoteGraphQlEventType } from '@app/graphql/generated/client/graphql.js';
|
||||
import { SEND_REMOTE_QUERY_RESPONSE } from '@app/graphql/mothership/mutations.js';
|
||||
import { parseGraphQLQuery } from '@app/graphql/resolvers/subscription/remote-graphql/remote-graphql-helpers.js';
|
||||
import { GraphQLClient } from '@app/mothership/graphql-client.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
|
||||
export const executeRemoteGraphQLQuery = async (
|
||||
data: RemoteGraphQlEventFragmentFragment['remoteGraphQLEventData']
|
||||
) => {
|
||||
remoteQueryLogger.debug({ query: data }, 'Executing remote query');
|
||||
const client = GraphQLClient.getInstance();
|
||||
const localApiKey = getters.config().remote.localApiKey;
|
||||
|
||||
if (!localApiKey) {
|
||||
throw new Error('Local API key is missing');
|
||||
}
|
||||
|
||||
const apiKey = localApiKey;
|
||||
const originalBody = data.body;
|
||||
|
||||
try {
|
||||
const parsedQuery = parseGraphQLQuery(originalBody);
|
||||
const localClient = getApiApolloClient({
|
||||
localApiKey: apiKey,
|
||||
});
|
||||
remoteQueryLogger.trace({ query: parsedQuery.query }, '[DEVONLY] Running query');
|
||||
const localResult = await localClient.query({
|
||||
query: parsedQuery.query,
|
||||
variables: parsedQuery.variables,
|
||||
});
|
||||
if (localResult.data) {
|
||||
remoteQueryLogger.trace(
|
||||
{ data: localResult.data },
|
||||
'Got data from remoteQuery request',
|
||||
data.sha256
|
||||
);
|
||||
|
||||
await client?.mutate({
|
||||
mutation: SEND_REMOTE_QUERY_RESPONSE,
|
||||
variables: {
|
||||
input: {
|
||||
sha256: data.sha256,
|
||||
body: JSON.stringify({ data: localResult.data }),
|
||||
type: RemoteGraphQlEventType.REMOTE_QUERY_EVENT,
|
||||
},
|
||||
},
|
||||
errorPolicy: 'none',
|
||||
});
|
||||
} else {
|
||||
// @TODO fix this not sending an error
|
||||
await client?.mutate({
|
||||
mutation: SEND_REMOTE_QUERY_RESPONSE,
|
||||
variables: {
|
||||
input: {
|
||||
sha256: data.sha256,
|
||||
body: JSON.stringify({ errors: localResult.error }),
|
||||
type: RemoteGraphQlEventType.REMOTE_QUERY_EVENT,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
try {
|
||||
await client?.mutate({
|
||||
mutation: SEND_REMOTE_QUERY_RESPONSE,
|
||||
variables: {
|
||||
input: {
|
||||
sha256: data.sha256,
|
||||
body: JSON.stringify({ errors: err }),
|
||||
type: RemoteGraphQlEventType.REMOTE_QUERY_EVENT,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
remoteQueryLogger.warn('Could not respond %o', error);
|
||||
}
|
||||
remoteQueryLogger.error(
|
||||
'Error executing remote query %s',
|
||||
err instanceof Error ? err.message : 'Unknown Error'
|
||||
);
|
||||
remoteQueryLogger.trace(err);
|
||||
}
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import { type RemoteGraphQlEventFragmentFragment } from '@app/graphql/generated/client/graphql.js';
|
||||
import { addRemoteSubscription } from '@app/store/actions/add-remote-subscription.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
|
||||
export const createRemoteSubscription = async (
|
||||
data: RemoteGraphQlEventFragmentFragment['remoteGraphQLEventData']
|
||||
) => {
|
||||
await store.dispatch(addRemoteSubscription(data));
|
||||
};
|
||||
@@ -83,7 +83,9 @@ export const getLocalServer = (getState = store.getState): Array<Server> => {
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'local',
|
||||
owner: {
|
||||
id: 'local',
|
||||
username: config.remote.username ?? 'root',
|
||||
url: '',
|
||||
avatar: '',
|
||||
|
||||
@@ -15,9 +15,9 @@ import { WebSocket } from 'ws';
|
||||
|
||||
import { logger } from '@app/core/log.js';
|
||||
import { fileExistsSync } from '@app/core/utils/files/file-exists.js';
|
||||
import { getServerIdentifier } from '@app/core/utils/server-identifier.js';
|
||||
import { environment, PATHS_CONFIG_MODULES, PORT } from '@app/environment.js';
|
||||
import * as envVars from '@app/environment.js';
|
||||
import { setupNewMothershipSubscription } from '@app/mothership/subscribe-to-mothership.js';
|
||||
import { loadDynamixConfigFile } from '@app/store/actions/load-dynamix-config-file.js';
|
||||
import { shutdownApiEvent } from '@app/store/actions/shutdown-api-event.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
@@ -40,8 +40,25 @@ const unlinkUnixPort = () => {
|
||||
|
||||
export const viteNodeApp = async () => {
|
||||
try {
|
||||
await import('json-bigint-patch');
|
||||
environment.IS_MAIN_PROCESS = true;
|
||||
|
||||
/**------------------------------------------------------------------------
|
||||
* Attaching getServerIdentifier to globalThis
|
||||
|
||||
* getServerIdentifier is tightly coupled to the deprecated redux store,
|
||||
* which we don't want to share with other packages or plugins.
|
||||
*
|
||||
* At the same time, we need to use it in @unraid/shared as a building block,
|
||||
* where it's used & available outside of NestJS's DI context.
|
||||
*
|
||||
* Attaching to globalThis is a temporary solution to avoid refactoring
|
||||
* config sync & management outside of NestJS's DI context.
|
||||
*
|
||||
* Plugin authors should import getServerIdentifier from @unraid/shared instead,
|
||||
* to avoid breaking changes to their code.
|
||||
*------------------------------------------------------------------------**/
|
||||
globalThis.getServerIdentifier = getServerIdentifier;
|
||||
logger.info('ENV %o', envVars);
|
||||
logger.info('PATHS %o', store.getState().paths);
|
||||
|
||||
@@ -70,8 +87,6 @@ export const viteNodeApp = async () => {
|
||||
// Load my dynamix config file into store
|
||||
await store.dispatch(loadDynamixConfigFile());
|
||||
|
||||
await setupNewMothershipSubscription();
|
||||
|
||||
// Start listening to file updates
|
||||
StateManager.getInstance();
|
||||
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
import type { NormalizedCacheObject } from '@apollo/client/core/index.js';
|
||||
import type { Client, Event as ClientEvent } from 'graphql-ws';
|
||||
import { ApolloClient, ApolloLink, InMemoryCache, Observable } from '@apollo/client/core/index.js';
|
||||
import { ErrorLink } from '@apollo/client/link/error/index.js';
|
||||
import { RetryLink } from '@apollo/client/link/retry/index.js';
|
||||
import { GraphQLWsLink } from '@apollo/client/link/subscriptions/index.js';
|
||||
import { createClient } from 'graphql-ws';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
import { FIVE_MINUTES_MS } from '@app/consts.js';
|
||||
import { minigraphLogger } from '@app/core/log.js';
|
||||
import { API_VERSION, MOTHERSHIP_GRAPHQL_LINK } from '@app/environment.js';
|
||||
import { buildDelayFunction } from '@app/mothership/utils/delay-function.js';
|
||||
import {
|
||||
getMothershipConnectionParams,
|
||||
getMothershipWebsocketHeaders,
|
||||
} from '@app/mothership/utils/get-mothership-websocket-headers.js';
|
||||
import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status.js';
|
||||
import { getters, store } from '@app/store/index.js';
|
||||
import { logoutUser } from '@app/store/modules/config.js';
|
||||
import { receivedMothershipPing, setMothershipTimeout } from '@app/store/modules/minigraph.js';
|
||||
import { MinigraphStatus } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js';
|
||||
|
||||
const getWebsocketWithMothershipHeaders = () => {
|
||||
return class WebsocketWithMothershipHeaders extends WebSocket {
|
||||
constructor(address, protocols) {
|
||||
super(address, protocols, {
|
||||
headers: getMothershipWebsocketHeaders(),
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const delayFn = buildDelayFunction({
|
||||
jitter: true,
|
||||
max: FIVE_MINUTES_MS,
|
||||
initial: 10_000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Checks that API_VERSION, config.remote.apiKey, emhttp.var.flashGuid, and emhttp.var.version are all set before returning true\
|
||||
* Also checks that the API Key has passed Validation from Keyserver
|
||||
* @returns boolean, are variables set
|
||||
*/
|
||||
export const isAPIStateDataFullyLoaded = (state = store.getState()) => {
|
||||
const { config, emhttp } = state;
|
||||
return (
|
||||
Boolean(API_VERSION) &&
|
||||
Boolean(config.remote.apikey) &&
|
||||
Boolean(emhttp.var.flashGuid) &&
|
||||
Boolean(emhttp.var.version)
|
||||
);
|
||||
};
|
||||
|
||||
const isInvalidApiKeyError = (error: unknown) =>
|
||||
error instanceof Error && error.message.includes('API Key Invalid');
|
||||
|
||||
export class GraphQLClient {
|
||||
public static instance: ApolloClient<NormalizedCacheObject> | null = null;
|
||||
public static client: Client | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Get a singleton GraphQL instance (if possible given loaded state)
|
||||
* @returns ApolloClient instance or null, if state is not valid
|
||||
*/
|
||||
public static getInstance(): ApolloClient<NormalizedCacheObject> | null {
|
||||
const isStateValid = isAPIStateDataFullyLoaded();
|
||||
if (!isStateValid) {
|
||||
minigraphLogger.error('GraphQL Client is not valid. Returning null for instance');
|
||||
return null;
|
||||
}
|
||||
|
||||
return GraphQLClient.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is used to create a new Apollo instance (if it is possible to do so)
|
||||
* This is used in order to facilitate a single instance existing at a time
|
||||
* @returns Apollo Instance (if creation was possible)
|
||||
*/
|
||||
public static createSingletonInstance = () => {
|
||||
const isStateValid = isAPIStateDataFullyLoaded();
|
||||
|
||||
if (!GraphQLClient.instance && isStateValid) {
|
||||
minigraphLogger.debug('Creating a new Apollo Client Instance');
|
||||
GraphQLClient.instance = GraphQLClient.createGraphqlClient();
|
||||
}
|
||||
|
||||
return GraphQLClient.instance;
|
||||
};
|
||||
|
||||
public static clearInstance = async () => {
|
||||
if (this.instance) {
|
||||
await this.instance.clearStore();
|
||||
this.instance?.stop();
|
||||
}
|
||||
|
||||
if (GraphQLClient.client) {
|
||||
GraphQLClient.clearClientEventHandlers();
|
||||
GraphQLClient.client.terminate();
|
||||
await GraphQLClient.client.dispose();
|
||||
GraphQLClient.client = null;
|
||||
}
|
||||
|
||||
GraphQLClient.instance = null;
|
||||
GraphQLClient.client = null;
|
||||
minigraphLogger.trace('Cleared GraphQl client & instance');
|
||||
};
|
||||
|
||||
static createGraphqlClient() {
|
||||
/** a graphql-ws client to communicate with mothership if user opts-in */
|
||||
GraphQLClient.client = createClient({
|
||||
url: MOTHERSHIP_GRAPHQL_LINK.replace('http', 'ws'),
|
||||
webSocketImpl: getWebsocketWithMothershipHeaders(),
|
||||
connectionParams: () => getMothershipConnectionParams(),
|
||||
});
|
||||
const wsLink = new GraphQLWsLink(GraphQLClient.client);
|
||||
const { appErrorLink, retryLink, errorLink } = GraphQLClient.createApolloLinks();
|
||||
|
||||
const apolloClient = new ApolloClient({
|
||||
link: ApolloLink.from([appErrorLink, retryLink, errorLink, wsLink]),
|
||||
cache: new InMemoryCache(),
|
||||
defaultOptions: {
|
||||
watchQuery: {
|
||||
fetchPolicy: 'no-cache',
|
||||
errorPolicy: 'all',
|
||||
},
|
||||
query: {
|
||||
fetchPolicy: 'no-cache',
|
||||
errorPolicy: 'all',
|
||||
},
|
||||
},
|
||||
});
|
||||
GraphQLClient.initEventHandlers();
|
||||
return apolloClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and configures Apollo links for error handling and retries
|
||||
*
|
||||
* @returns Object containing configured Apollo links:
|
||||
* - appErrorLink: Prevents errors from bubbling "up" & potentially crashing the API
|
||||
* - retryLink: Handles retrying failed operations with exponential backoff
|
||||
* - errorLink: Handles GraphQL and network errors, including API key validation and connection status updates
|
||||
*/
|
||||
static createApolloLinks() {
|
||||
/** prevents errors from bubbling beyond this link/apollo instance & potentially crashing the api */
|
||||
const appErrorLink = new ApolloLink((operation, forward) => {
|
||||
return new Observable((observer) => {
|
||||
forward(operation).subscribe({
|
||||
next: (result) => observer.next(result),
|
||||
error: (error) => {
|
||||
minigraphLogger.warn('Apollo error, will not retry: %s', error?.message);
|
||||
observer.complete();
|
||||
},
|
||||
complete: () => observer.complete(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Max # of times to retry authenticating with mothership.
|
||||
* Total # of attempts will be retries + 1.
|
||||
*/
|
||||
const MAX_AUTH_RETRIES = 3;
|
||||
const retryLink = new RetryLink({
|
||||
delay(count, operation, error) {
|
||||
const getDelay = delayFn(count);
|
||||
operation.setContext({ retryCount: count });
|
||||
store.dispatch(setMothershipTimeout(getDelay));
|
||||
minigraphLogger.info('Delay currently is: %i', getDelay);
|
||||
return getDelay;
|
||||
},
|
||||
attempts: {
|
||||
max: Infinity,
|
||||
retryIf: (error, operation) => {
|
||||
const { retryCount = 0 } = operation.getContext();
|
||||
// i.e. retry api key errors up to 3 times (4 attempts total)
|
||||
return !isInvalidApiKeyError(error) || retryCount < MAX_AUTH_RETRIES;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const errorLink = new ErrorLink((handler) => {
|
||||
const { retryCount = 0 } = handler.operation.getContext();
|
||||
minigraphLogger.debug(`Operation attempt: #${retryCount}`);
|
||||
if (handler.graphQLErrors) {
|
||||
// GQL Error Occurred, we should log and move on
|
||||
minigraphLogger.info('GQL Error Encountered %o', handler.graphQLErrors);
|
||||
} else if (handler.networkError) {
|
||||
/**----------------------------------------------
|
||||
* Handling of Network Errors
|
||||
*
|
||||
* When the handler has a void return,
|
||||
* the network error will bubble up
|
||||
* (i.e. left in the `ApolloLink.from` array).
|
||||
*
|
||||
* The underlying operation/request
|
||||
* may be retried per the retry link.
|
||||
*
|
||||
* If the error is not retried, it will bubble
|
||||
* into the appErrorLink and terminate there.
|
||||
*---------------------------------------------**/
|
||||
minigraphLogger.error(handler.networkError, 'Network Error');
|
||||
const error = handler.networkError;
|
||||
|
||||
if (error?.message?.includes('to be an array of GraphQL errors, but got')) {
|
||||
minigraphLogger.warn('detected malformed graphql error in websocket message');
|
||||
}
|
||||
|
||||
if (isInvalidApiKeyError(error)) {
|
||||
if (retryCount >= MAX_AUTH_RETRIES) {
|
||||
store
|
||||
.dispatch(logoutUser({ reason: 'Invalid API Key on Mothership' }))
|
||||
.catch((err) => {
|
||||
minigraphLogger.error(err, 'Error during logout');
|
||||
});
|
||||
}
|
||||
} else if (getters.minigraph().status !== MinigraphStatus.ERROR_RETRYING) {
|
||||
store.dispatch(
|
||||
setGraphqlConnectionStatus({
|
||||
status: MinigraphStatus.ERROR_RETRYING,
|
||||
error: handler.networkError.message,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
return { appErrorLink, retryLink, errorLink } as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize event handlers for the GraphQL client websocket connection
|
||||
*
|
||||
* Sets up handlers for:
|
||||
* - 'connecting': Updates store with connecting status and logs connection attempt
|
||||
* - 'error': Logs any GraphQL client errors
|
||||
* - 'connected': Updates store with connected status and logs successful connection
|
||||
* - 'ping': Handles ping messages from mothership to track connection health
|
||||
*
|
||||
* @param client - The GraphQL client instance to attach handlers to. Defaults to GraphQLClient.client
|
||||
* @returns void
|
||||
*/
|
||||
private static initEventHandlers(client = GraphQLClient.client): void {
|
||||
if (!client) return;
|
||||
// Maybe a listener to initiate this
|
||||
client.on('connecting', () => {
|
||||
store.dispatch(
|
||||
setGraphqlConnectionStatus({
|
||||
status: MinigraphStatus.CONNECTING,
|
||||
error: null,
|
||||
})
|
||||
);
|
||||
minigraphLogger.info('Connecting to %s', MOTHERSHIP_GRAPHQL_LINK.replace('http', 'ws'));
|
||||
});
|
||||
client.on('error', (err) => {
|
||||
minigraphLogger.error('GraphQL Client Error: %o', err);
|
||||
});
|
||||
client.on('connected', () => {
|
||||
store.dispatch(
|
||||
setGraphqlConnectionStatus({
|
||||
status: MinigraphStatus.CONNECTED,
|
||||
error: null,
|
||||
})
|
||||
);
|
||||
minigraphLogger.info('Connected to %s', MOTHERSHIP_GRAPHQL_LINK.replace('http', 'ws'));
|
||||
});
|
||||
|
||||
client.on('ping', () => {
|
||||
// Received ping from mothership
|
||||
minigraphLogger.trace('ping');
|
||||
store.dispatch(receivedMothershipPing());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears event handlers from the GraphQL client websocket connection
|
||||
*
|
||||
* Removes handlers for the specified events by replacing them with empty functions.
|
||||
* This ensures no lingering event handlers remain when disposing of a client.
|
||||
*
|
||||
* @param client - The GraphQL client instance to clear handlers from. Defaults to GraphQLClient.client
|
||||
* @param events - Array of event types to clear handlers for. Defaults to ['connected', 'connecting', 'error', 'ping']
|
||||
* @returns void
|
||||
*/
|
||||
private static clearClientEventHandlers(
|
||||
client = GraphQLClient.client,
|
||||
events: ClientEvent[] = ['connected', 'connecting', 'error', 'ping']
|
||||
): void {
|
||||
if (!client) return;
|
||||
events.forEach((eventName) => client.on(eventName, () => {}));
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { CronJob } from 'cron';
|
||||
|
||||
import { KEEP_ALIVE_INTERVAL_MS, ONE_MINUTE_MS } from '@app/consts.js';
|
||||
import { minigraphLogger, mothershipLogger, remoteAccessLogger } from '@app/core/log.js';
|
||||
import { isAPIStateDataFullyLoaded } from '@app/mothership/graphql-client.js';
|
||||
import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { setRemoteAccessRunningType } from '@app/store/modules/dynamic-remote-access.js';
|
||||
import { clearSubscription } from '@app/store/modules/remote-graphql.js';
|
||||
import { MinigraphStatus } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js';
|
||||
import { DynamicRemoteAccessType } from '@app/unraid-api/graph/resolvers/connect/connect.model.js';
|
||||
|
||||
class PingTimeoutJobs {
|
||||
private cronJob: CronJob;
|
||||
private isRunning: boolean = false;
|
||||
|
||||
constructor() {
|
||||
// Run every minute
|
||||
this.cronJob = new CronJob('* * * * *', this.checkForPingTimeouts.bind(this));
|
||||
}
|
||||
|
||||
async checkForPingTimeouts() {
|
||||
const state = store.getState();
|
||||
if (!isAPIStateDataFullyLoaded(state)) {
|
||||
mothershipLogger.warn('State data not fully loaded, but job has been started');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for ping timeouts in remote graphql events
|
||||
const subscriptionsToClear = state.remoteGraphQL.subscriptions.filter(
|
||||
(subscription) => Date.now() - subscription.lastPing > KEEP_ALIVE_INTERVAL_MS
|
||||
);
|
||||
if (subscriptionsToClear.length > 0) {
|
||||
mothershipLogger.debug(
|
||||
`Clearing %s / %s subscriptions that are older than ${
|
||||
KEEP_ALIVE_INTERVAL_MS / 1_000 / 60
|
||||
} minutes`,
|
||||
subscriptionsToClear.length,
|
||||
state.remoteGraphQL.subscriptions.length
|
||||
);
|
||||
}
|
||||
|
||||
subscriptionsToClear.forEach((sub) => store.dispatch(clearSubscription(sub.sha256)));
|
||||
|
||||
// Check for ping timeouts in mothership
|
||||
if (
|
||||
state.minigraph.lastPing &&
|
||||
Date.now() - state.minigraph.lastPing > KEEP_ALIVE_INTERVAL_MS &&
|
||||
state.minigraph.status === MinigraphStatus.CONNECTED
|
||||
) {
|
||||
minigraphLogger.error(
|
||||
`NO PINGS RECEIVED IN ${
|
||||
KEEP_ALIVE_INTERVAL_MS / 1_000 / 60
|
||||
} MINUTES, SOCKET MUST BE RECONNECTED`
|
||||
);
|
||||
store.dispatch(
|
||||
setGraphqlConnectionStatus({
|
||||
status: MinigraphStatus.PING_FAILURE,
|
||||
error: 'Ping Receive Exceeded Timeout',
|
||||
})
|
||||
);
|
||||
}
|
||||
// Check for ping timeouts from mothership events
|
||||
if (
|
||||
state.minigraph.selfDisconnectedSince &&
|
||||
Date.now() - state.minigraph.selfDisconnectedSince > KEEP_ALIVE_INTERVAL_MS &&
|
||||
state.minigraph.status === MinigraphStatus.CONNECTED
|
||||
) {
|
||||
minigraphLogger.error(`SELF DISCONNECTION EVENT NEVER CLEARED, SOCKET MUST BE RECONNECTED`);
|
||||
store.dispatch(
|
||||
setGraphqlConnectionStatus({
|
||||
status: MinigraphStatus.PING_FAILURE,
|
||||
error: 'Received disconnect event for own server',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Check for ping timeouts in remote access
|
||||
if (
|
||||
state.dynamicRemoteAccess.lastPing &&
|
||||
Date.now() - state.dynamicRemoteAccess.lastPing > ONE_MINUTE_MS
|
||||
) {
|
||||
remoteAccessLogger.error(`NO PINGS RECEIVED IN 1 MINUTE, REMOTE ACCESS MUST BE DISABLED`);
|
||||
store.dispatch(setRemoteAccessRunningType(DynamicRemoteAccessType.DISABLED));
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this.isRunning) {
|
||||
this.cronJob.start();
|
||||
this.isRunning = true;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.isRunning) {
|
||||
this.cronJob.stop();
|
||||
this.isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
isJobRunning(): boolean {
|
||||
return this.isRunning;
|
||||
}
|
||||
}
|
||||
|
||||
let pingTimeoutJobs: PingTimeoutJobs | null = null;
|
||||
|
||||
export const initPingTimeoutJobs = (): boolean => {
|
||||
if (!pingTimeoutJobs) {
|
||||
pingTimeoutJobs = new PingTimeoutJobs();
|
||||
}
|
||||
pingTimeoutJobs.start();
|
||||
return pingTimeoutJobs.isJobRunning();
|
||||
};
|
||||
|
||||
export const stopPingTimeoutJobs = () => {
|
||||
minigraphLogger.trace('Stopping Ping Timeout Jobs');
|
||||
if (!pingTimeoutJobs) {
|
||||
minigraphLogger.warn('PingTimeoutJobs Handler not found.');
|
||||
return;
|
||||
}
|
||||
pingTimeoutJobs.stop();
|
||||
pingTimeoutJobs = null;
|
||||
};
|
||||
@@ -1,88 +0,0 @@
|
||||
import { minigraphLogger, mothershipLogger } from '@app/core/log.js';
|
||||
import { useFragment } from '@app/graphql/generated/client/fragment-masking.js';
|
||||
import { ClientType } from '@app/graphql/generated/client/graphql.js';
|
||||
import { EVENTS_SUBSCRIPTION, RemoteGraphQL_Fragment } from '@app/graphql/mothership/subscriptions.js';
|
||||
import { GraphQLClient } from '@app/mothership/graphql-client.js';
|
||||
import { initPingTimeoutJobs } from '@app/mothership/jobs/ping-timeout-jobs.js';
|
||||
import { getMothershipConnectionParams } from '@app/mothership/utils/get-mothership-websocket-headers.js';
|
||||
import { handleRemoteGraphQLEvent } from '@app/store/actions/handle-remote-graphql-event.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { setSelfDisconnected, setSelfReconnected } from '@app/store/modules/minigraph.js';
|
||||
import { notNull } from '@app/utils.js';
|
||||
|
||||
export const subscribeToEvents = async (apiKey: string) => {
|
||||
minigraphLogger.info('Subscribing to Events');
|
||||
const client = GraphQLClient.getInstance();
|
||||
if (!client) {
|
||||
throw new Error('Unable to use client - state must not be loaded');
|
||||
}
|
||||
|
||||
const eventsSub = client.subscribe({
|
||||
query: EVENTS_SUBSCRIPTION,
|
||||
fetchPolicy: 'no-cache',
|
||||
});
|
||||
eventsSub.subscribe(async ({ data, errors }) => {
|
||||
if (errors) {
|
||||
mothershipLogger.error('GraphQL Error with events subscription: %s', errors.join(','));
|
||||
} else if (data) {
|
||||
mothershipLogger.trace({ events: data.events }, 'Got events from mothership');
|
||||
|
||||
for (const event of data.events?.filter(notNull) ?? []) {
|
||||
switch (event.__typename) {
|
||||
case 'ClientConnectedEvent': {
|
||||
const {
|
||||
connectedData: { type, apiKey: eventApiKey },
|
||||
} = event;
|
||||
// Another server connected to Mothership
|
||||
if (type === ClientType.API) {
|
||||
if (eventApiKey === apiKey) {
|
||||
// We are online, clear timeout waiting if it's set
|
||||
store.dispatch(setSelfReconnected());
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ClientDisconnectedEvent': {
|
||||
const {
|
||||
disconnectedData: { type, apiKey: eventApiKey },
|
||||
} = event;
|
||||
// Server Disconnected From Mothership
|
||||
if (type === ClientType.API) {
|
||||
if (eventApiKey === apiKey) {
|
||||
store.dispatch(setSelfDisconnected());
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'RemoteGraphQLEvent': {
|
||||
const eventAsRemoteGraphQLEvent = useFragment(RemoteGraphQL_Fragment, event);
|
||||
// No need to check API key here anymore
|
||||
|
||||
void store.dispatch(handleRemoteGraphQLEvent(eventAsRemoteGraphQLEvent));
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const setupNewMothershipSubscription = async (state = store.getState()) => {
|
||||
await GraphQLClient.clearInstance();
|
||||
if (getMothershipConnectionParams(state)?.apiKey) {
|
||||
minigraphLogger.trace('Creating Graphql client');
|
||||
const client = GraphQLClient.createSingletonInstance();
|
||||
if (client) {
|
||||
minigraphLogger.trace('Connecting to mothership');
|
||||
await subscribeToEvents(state.config.remote.apikey);
|
||||
initPingTimeoutJobs();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
import { type OutgoingHttpHeaders } from 'node:http2';
|
||||
|
||||
import { logger } from '@app/core/log.js';
|
||||
import { API_VERSION } from '@app/environment.js';
|
||||
import { ClientType } from '@app/graphql/generated/client/graphql.js';
|
||||
import { isAPIStateDataFullyLoaded } from '@app/mothership/graphql-client.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
|
||||
interface MothershipWebsocketHeaders extends OutgoingHttpHeaders {
|
||||
'x-api-key': string;
|
||||
'x-flash-guid': string;
|
||||
'x-unraid-api-version': string;
|
||||
'x-unraid-server-version': string;
|
||||
'User-Agent': string;
|
||||
}
|
||||
|
||||
export const getMothershipWebsocketHeaders = (
|
||||
state = store.getState()
|
||||
): MothershipWebsocketHeaders | OutgoingHttpHeaders => {
|
||||
const { config, emhttp } = state;
|
||||
if (isAPIStateDataFullyLoaded(state)) {
|
||||
const headers: MothershipWebsocketHeaders = {
|
||||
'x-api-key': config.remote.apikey,
|
||||
'x-flash-guid': emhttp.var.flashGuid,
|
||||
'x-unraid-api-version': API_VERSION,
|
||||
'x-unraid-server-version': emhttp.var.version,
|
||||
'User-Agent': `unraid-api/${API_VERSION}`,
|
||||
};
|
||||
logger.debug('Mothership websocket headers: %o', headers);
|
||||
return headers;
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
interface MothershipConnectionParams extends Record<string, unknown> {
|
||||
clientType: ClientType;
|
||||
apiKey: string;
|
||||
flashGuid: string;
|
||||
apiVersion: string;
|
||||
unraidVersion: string;
|
||||
}
|
||||
|
||||
export const getMothershipConnectionParams = (
|
||||
state = store.getState()
|
||||
): MothershipConnectionParams | Record<string, unknown> => {
|
||||
const { config, emhttp } = state;
|
||||
if (isAPIStateDataFullyLoaded(state)) {
|
||||
return {
|
||||
clientType: ClientType.API,
|
||||
apiKey: config.remote.apikey,
|
||||
flashGuid: emhttp.var.flashGuid,
|
||||
apiVersion: API_VERSION,
|
||||
unraidVersion: emhttp.var.version,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
import { type AppDispatch, type RootState } from '@app/store/index.js';
|
||||
import { AccessUrl } from '@app/unraid-api/graph/resolvers/connect/connect.model.js';
|
||||
|
||||
export interface GenericRemoteAccess {
|
||||
beginRemoteAccess({
|
||||
getState,
|
||||
dispatch,
|
||||
}: {
|
||||
getState: () => RootState;
|
||||
dispatch: AppDispatch;
|
||||
}): Promise<AccessUrl | null>;
|
||||
stopRemoteAccess({
|
||||
getState,
|
||||
dispatch,
|
||||
}: {
|
||||
getState: () => RootState;
|
||||
dispatch: AppDispatch;
|
||||
}): Promise<void>;
|
||||
getRemoteAccessUrl({ getState }: { getState: () => RootState }): AccessUrl | null;
|
||||
}
|
||||
|
||||
export interface IRemoteAccessController extends GenericRemoteAccess {
|
||||
extendRemoteAccess({
|
||||
getState,
|
||||
dispatch,
|
||||
}: {
|
||||
getState: () => RootState;
|
||||
dispatch: AppDispatch;
|
||||
}): void;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { remoteAccessLogger } from '@app/core/log.js';
|
||||
import { getServerIps } from '@app/graphql/resolvers/subscription/network.js';
|
||||
import { type GenericRemoteAccess } from '@app/remoteAccess/handlers/remote-access-interface.js';
|
||||
import { setWanAccessAndReloadNginx } from '@app/store/actions/set-wan-access-with-reload.js';
|
||||
import { type AppDispatch, type RootState } from '@app/store/index.js';
|
||||
import {
|
||||
AccessUrl,
|
||||
DynamicRemoteAccessType,
|
||||
URL_TYPE,
|
||||
} from '@app/unraid-api/graph/resolvers/connect/connect.model.js';
|
||||
|
||||
export class StaticRemoteAccess implements GenericRemoteAccess {
|
||||
public getRemoteAccessUrl({ getState }: { getState: () => RootState }): AccessUrl | null {
|
||||
const url = getServerIps(getState()).urls.find((url) => url.type === URL_TYPE.WAN);
|
||||
return url ?? null;
|
||||
}
|
||||
|
||||
async beginRemoteAccess({
|
||||
getState,
|
||||
dispatch,
|
||||
}: {
|
||||
getState: () => RootState;
|
||||
dispatch: AppDispatch;
|
||||
}): Promise<AccessUrl | null> {
|
||||
const {
|
||||
config: {
|
||||
remote: { dynamicRemoteAccessType },
|
||||
},
|
||||
} = getState();
|
||||
if (dynamicRemoteAccessType === DynamicRemoteAccessType.STATIC) {
|
||||
remoteAccessLogger.debug('Enabling remote access for Static Client');
|
||||
await dispatch(setWanAccessAndReloadNginx('yes'));
|
||||
return this.getRemoteAccessUrl({ getState });
|
||||
}
|
||||
|
||||
throw new Error('Invalid Parameters Passed to Static Remote Access Enabler');
|
||||
}
|
||||
|
||||
async stopRemoteAccess({
|
||||
dispatch,
|
||||
}: {
|
||||
getState: () => RootState;
|
||||
dispatch: AppDispatch;
|
||||
}): Promise<void> {
|
||||
await dispatch(setWanAccessAndReloadNginx('no'));
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { remoteAccessLogger } from '@app/core/log.js';
|
||||
import { getServerIps } from '@app/graphql/resolvers/subscription/network.js';
|
||||
import { type GenericRemoteAccess } from '@app/remoteAccess/handlers/remote-access-interface.js';
|
||||
import { setWanAccessAndReloadNginx } from '@app/store/actions/set-wan-access-with-reload.js';
|
||||
import { type AppDispatch, type RootState } from '@app/store/index.js';
|
||||
import { disableUpnp, enableUpnp } from '@app/store/modules/upnp.js';
|
||||
import {
|
||||
AccessUrl,
|
||||
DynamicRemoteAccessType,
|
||||
URL_TYPE,
|
||||
} from '@app/unraid-api/graph/resolvers/connect/connect.model.js';
|
||||
|
||||
export class UpnpRemoteAccess implements GenericRemoteAccess {
|
||||
async stopRemoteAccess({ dispatch }: { getState: () => RootState; dispatch: AppDispatch }) {
|
||||
// Stop
|
||||
await dispatch(disableUpnp());
|
||||
await dispatch(setWanAccessAndReloadNginx('no'));
|
||||
}
|
||||
|
||||
public getRemoteAccessUrl({ getState }: { getState: () => RootState }): AccessUrl | null {
|
||||
const urlsForServer = getServerIps(getState());
|
||||
const url = urlsForServer.urls.find((url) => url.type === URL_TYPE.WAN) ?? null;
|
||||
|
||||
return url ?? null;
|
||||
}
|
||||
|
||||
async beginRemoteAccess({
|
||||
getState,
|
||||
dispatch,
|
||||
}: {
|
||||
getState: () => RootState;
|
||||
dispatch: AppDispatch;
|
||||
}) {
|
||||
// Stop Close Event
|
||||
const state = getState();
|
||||
const { dynamicRemoteAccessType } = state.config.remote;
|
||||
if (dynamicRemoteAccessType === DynamicRemoteAccessType.UPNP && !state.upnp.upnpEnabled) {
|
||||
const { portssl } = state.emhttp.var;
|
||||
try {
|
||||
const upnpEnableResult = await dispatch(enableUpnp({ portssl })).unwrap();
|
||||
await dispatch(setWanAccessAndReloadNginx('yes'));
|
||||
|
||||
remoteAccessLogger.debug('UPNP Enable Result', upnpEnableResult);
|
||||
|
||||
if (!upnpEnableResult.wanPortForUpnp) {
|
||||
throw new Error('Failed to get a WAN Port from UPNP');
|
||||
}
|
||||
|
||||
return this.getRemoteAccessUrl({ getState });
|
||||
} catch (error: unknown) {
|
||||
remoteAccessLogger.warn('Caught error, disabling UPNP and re-throwing');
|
||||
await this.stopRemoteAccess({ dispatch, getState });
|
||||
throw new Error(
|
||||
`UPNP Dynamic Remote Access Error: ${
|
||||
error instanceof Error ? error.message : 'Unknown Error'
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid Parameters Passed to UPNP Remote Access Enabler');
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import type { AppDispatch, RootState } from '@app/store/index.js';
|
||||
import { remoteAccessLogger } from '@app/core/log.js';
|
||||
import { UnraidLocalNotifier } from '@app/core/notifiers/unraid-local.js';
|
||||
import { type IRemoteAccessController } from '@app/remoteAccess/handlers/remote-access-interface.js';
|
||||
import { StaticRemoteAccess } from '@app/remoteAccess/handlers/static-remote-access.js';
|
||||
import { UpnpRemoteAccess } from '@app/remoteAccess/handlers/upnp-remote-access.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
import {
|
||||
clearPing,
|
||||
receivedPing,
|
||||
setDynamicRemoteAccessError,
|
||||
setRemoteAccessRunningType,
|
||||
} from '@app/store/modules/dynamic-remote-access.js';
|
||||
import {
|
||||
AccessUrl,
|
||||
DynamicRemoteAccessType,
|
||||
} from '@app/unraid-api/graph/resolvers/connect/connect.model.js';
|
||||
|
||||
export class RemoteAccessController implements IRemoteAccessController {
|
||||
static _instance: RemoteAccessController | null = null;
|
||||
activeRemoteAccess: UpnpRemoteAccess | StaticRemoteAccess | null = null;
|
||||
notifier: UnraidLocalNotifier = new UnraidLocalNotifier({ level: 'info' });
|
||||
|
||||
constructor() {}
|
||||
|
||||
public static get instance(): RemoteAccessController {
|
||||
if (!RemoteAccessController._instance) {
|
||||
RemoteAccessController._instance = new RemoteAccessController();
|
||||
}
|
||||
|
||||
return RemoteAccessController._instance;
|
||||
}
|
||||
|
||||
getRunningRemoteAccessType() {
|
||||
return getters.dynamicRemoteAccess().runningType;
|
||||
}
|
||||
|
||||
public getRemoteAccessUrl({ getState }: { getState: () => RootState }): AccessUrl | null {
|
||||
if (!this.activeRemoteAccess) {
|
||||
return null;
|
||||
}
|
||||
return this.activeRemoteAccess.getRemoteAccessUrl({ getState });
|
||||
}
|
||||
|
||||
async beginRemoteAccess({
|
||||
getState,
|
||||
dispatch,
|
||||
}: {
|
||||
getState: () => RootState;
|
||||
dispatch: AppDispatch;
|
||||
}) {
|
||||
const state = getState();
|
||||
const {
|
||||
config: {
|
||||
remote: { dynamicRemoteAccessType },
|
||||
},
|
||||
dynamicRemoteAccess: { runningType },
|
||||
} = state;
|
||||
|
||||
if (!dynamicRemoteAccessType) {
|
||||
// Should never get here
|
||||
return null;
|
||||
}
|
||||
|
||||
remoteAccessLogger.debug('Beginning remote access', runningType, dynamicRemoteAccessType);
|
||||
if (runningType !== dynamicRemoteAccessType) {
|
||||
await this.activeRemoteAccess?.stopRemoteAccess({
|
||||
getState,
|
||||
dispatch,
|
||||
});
|
||||
}
|
||||
|
||||
switch (dynamicRemoteAccessType) {
|
||||
case DynamicRemoteAccessType.DISABLED:
|
||||
this.activeRemoteAccess = null;
|
||||
remoteAccessLogger.debug('Received begin event, but DRA is disabled.');
|
||||
break;
|
||||
case DynamicRemoteAccessType.UPNP:
|
||||
remoteAccessLogger.debug('UPNP DRA Begin');
|
||||
this.activeRemoteAccess = new UpnpRemoteAccess();
|
||||
break;
|
||||
case DynamicRemoteAccessType.STATIC:
|
||||
remoteAccessLogger.debug('Static DRA Begin');
|
||||
this.activeRemoteAccess = new StaticRemoteAccess();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Essentially a super call to the active type
|
||||
try {
|
||||
await this.activeRemoteAccess?.beginRemoteAccess({
|
||||
getState,
|
||||
dispatch,
|
||||
});
|
||||
dispatch(setRemoteAccessRunningType(dynamicRemoteAccessType));
|
||||
this.extendRemoteAccess({ getState, dispatch });
|
||||
await this.notifier.send({
|
||||
title: 'Remote Access Started',
|
||||
data: { message: 'Remote access has been started' },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
dispatch(
|
||||
setDynamicRemoteAccessError(error instanceof Error ? error.message : 'Unknown Error')
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public extendRemoteAccess({
|
||||
getState,
|
||||
dispatch,
|
||||
}: {
|
||||
getState: () => RootState;
|
||||
dispatch: AppDispatch;
|
||||
}) {
|
||||
dispatch(receivedPing());
|
||||
return this.getRemoteAccessUrl({ getState });
|
||||
}
|
||||
|
||||
async stopRemoteAccess({
|
||||
getState,
|
||||
dispatch,
|
||||
}: {
|
||||
getState: () => RootState;
|
||||
dispatch: AppDispatch;
|
||||
}) {
|
||||
remoteAccessLogger.debug('Stopping remote access');
|
||||
dispatch(clearPing());
|
||||
await this.activeRemoteAccess?.stopRemoteAccess({ getState, dispatch });
|
||||
|
||||
dispatch(setRemoteAccessRunningType(DynamicRemoteAccessType.DISABLED));
|
||||
await this.notifier.send({
|
||||
title: 'Remote Access Stopped',
|
||||
data: { message: 'Remote access has been stopped' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
|
||||
import type { RemoteGraphQlEventFragmentFragment } from '@app/graphql/generated/client/graphql.js';
|
||||
import { remoteQueryLogger } from '@app/core/log.js';
|
||||
import { getApiApolloClient } from '@app/graphql/client/api/get-api-client.js';
|
||||
import { RemoteGraphQlEventType } from '@app/graphql/generated/client/graphql.js';
|
||||
import { SEND_REMOTE_QUERY_RESPONSE } from '@app/graphql/mothership/mutations.js';
|
||||
import { parseGraphQLQuery } from '@app/graphql/resolvers/subscription/remote-graphql/remote-graphql-helpers.js';
|
||||
import { GraphQLClient } from '@app/mothership/graphql-client.js';
|
||||
import { hasRemoteSubscription } from '@app/store/getters/index.js';
|
||||
import { type AppDispatch, type RootState } from '@app/store/index.js';
|
||||
import { type SubscriptionWithSha256 } from '@app/store/types.js';
|
||||
|
||||
export const addRemoteSubscription = createAsyncThunk<
|
||||
SubscriptionWithSha256,
|
||||
RemoteGraphQlEventFragmentFragment['remoteGraphQLEventData'],
|
||||
{ state: RootState; dispatch: AppDispatch }
|
||||
>('remoteGraphQL/addRemoteSubscription', async (data, { getState }) => {
|
||||
if (hasRemoteSubscription(data.sha256, getState())) {
|
||||
throw new Error(`Subscription Already Exists for SHA256: ${data.sha256}`);
|
||||
}
|
||||
|
||||
const { config } = getState();
|
||||
|
||||
remoteQueryLogger.debug('Creating subscription for %o', data);
|
||||
const apiKey = config.remote.localApiKey;
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('Local API key is missing');
|
||||
}
|
||||
|
||||
const body = parseGraphQLQuery(data.body);
|
||||
const client = getApiApolloClient({
|
||||
localApiKey: apiKey,
|
||||
});
|
||||
const mothershipClient = GraphQLClient.getInstance();
|
||||
const observable = client.subscribe({
|
||||
query: body.query,
|
||||
variables: body.variables,
|
||||
});
|
||||
const subscription = observable.subscribe({
|
||||
async next(val) {
|
||||
remoteQueryLogger.debug('Got value %o', val);
|
||||
if (val.data) {
|
||||
const result = await mothershipClient?.mutate({
|
||||
mutation: SEND_REMOTE_QUERY_RESPONSE,
|
||||
variables: {
|
||||
input: {
|
||||
sha256: data.sha256,
|
||||
body: JSON.stringify({ data: val.data }),
|
||||
type: RemoteGraphQlEventType.REMOTE_SUBSCRIPTION_EVENT,
|
||||
},
|
||||
},
|
||||
});
|
||||
remoteQueryLogger.debug('Remote Query Publish Result %o', result);
|
||||
}
|
||||
},
|
||||
async error(errorValue) {
|
||||
try {
|
||||
await mothershipClient?.mutate({
|
||||
mutation: SEND_REMOTE_QUERY_RESPONSE,
|
||||
variables: {
|
||||
input: {
|
||||
sha256: data.sha256,
|
||||
body: JSON.stringify({ errors: errorValue }),
|
||||
type: RemoteGraphQlEventType.REMOTE_SUBSCRIPTION_EVENT,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
remoteQueryLogger.info('Failed to mutate error result to endpoint');
|
||||
}
|
||||
remoteQueryLogger.error('Error executing remote subscription: %o', errorValue);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
sha256: data.sha256,
|
||||
subscription,
|
||||
};
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
|
||||
import type { RemoteGraphQlEventFragmentFragment } from '@app/graphql/generated/client/graphql.js';
|
||||
import { remoteQueryLogger } from '@app/core/log.js';
|
||||
import { RemoteGraphQlEventType } from '@app/graphql/generated/client/graphql.js';
|
||||
import { executeRemoteGraphQLQuery } from '@app/graphql/resolvers/subscription/remote-graphql/remote-query.js';
|
||||
import { createRemoteSubscription } from '@app/graphql/resolvers/subscription/remote-graphql/remote-subscription.js';
|
||||
import { type AppDispatch, type RootState } from '@app/store/index.js';
|
||||
import { renewRemoteSubscription } from '@app/store/modules/remote-graphql.js';
|
||||
|
||||
export const handleRemoteGraphQLEvent = createAsyncThunk<
|
||||
void,
|
||||
RemoteGraphQlEventFragmentFragment,
|
||||
{ state: RootState; dispatch: AppDispatch }
|
||||
>('dynamicRemoteAccess/handleRemoteAccessEvent', async (event, { dispatch }) => {
|
||||
const data = event.remoteGraphQLEventData;
|
||||
switch (data.type) {
|
||||
case RemoteGraphQlEventType.REMOTE_MUTATION_EVENT:
|
||||
break;
|
||||
case RemoteGraphQlEventType.REMOTE_QUERY_EVENT:
|
||||
remoteQueryLogger.debug('Responding to remote query event');
|
||||
return await executeRemoteGraphQLQuery(event.remoteGraphQLEventData);
|
||||
case RemoteGraphQlEventType.REMOTE_SUBSCRIPTION_EVENT:
|
||||
remoteQueryLogger.debug('Responding to remote subscription event');
|
||||
return await createRemoteSubscription(data);
|
||||
case RemoteGraphQlEventType.REMOTE_SUBSCRIPTION_EVENT_PING:
|
||||
await dispatch(renewRemoteSubscription({ sha256: data.sha256 }));
|
||||
break;
|
||||
}
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
|
||||
import { type AppDispatch, type RootState } from '@app/store/index.js';
|
||||
import { type MyServersConfig } from '@app/types/my-servers-config.js';
|
||||
import {
|
||||
DynamicRemoteAccessType,
|
||||
SetupRemoteAccessInput,
|
||||
WAN_ACCESS_TYPE,
|
||||
WAN_FORWARD_TYPE,
|
||||
} from '@app/unraid-api/graph/resolvers/connect/connect.model.js';
|
||||
|
||||
const getDynamicRemoteAccessType = (
|
||||
accessType: WAN_ACCESS_TYPE,
|
||||
forwardType?: WAN_FORWARD_TYPE | undefined | null
|
||||
): DynamicRemoteAccessType => {
|
||||
// If access is disabled or always, DRA is disabled
|
||||
if (accessType === WAN_ACCESS_TYPE.DISABLED || accessType === WAN_ACCESS_TYPE.ALWAYS) {
|
||||
return DynamicRemoteAccessType.DISABLED;
|
||||
}
|
||||
// if access is enabled and forward type is UPNP, DRA is UPNP, otherwise it is static
|
||||
return forwardType === WAN_FORWARD_TYPE.UPNP
|
||||
? DynamicRemoteAccessType.UPNP
|
||||
: DynamicRemoteAccessType.STATIC;
|
||||
};
|
||||
|
||||
export const setupRemoteAccessThunk = createAsyncThunk<
|
||||
Pick<MyServersConfig['remote'], 'wanaccess' | 'wanport' | 'dynamicRemoteAccessType' | 'upnpEnabled'>,
|
||||
SetupRemoteAccessInput,
|
||||
{ state: RootState; dispatch: AppDispatch }
|
||||
>('config/setupRemoteAccess', async (payload) => {
|
||||
if (payload.accessType === WAN_ACCESS_TYPE.DISABLED) {
|
||||
return {
|
||||
wanaccess: 'no',
|
||||
wanport: '',
|
||||
dynamicRemoteAccessType: DynamicRemoteAccessType.DISABLED,
|
||||
upnpEnabled: 'no',
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.forwardType === WAN_FORWARD_TYPE.STATIC && !payload.port) {
|
||||
throw new Error('Missing port for WAN forward type STATIC');
|
||||
}
|
||||
|
||||
return {
|
||||
wanaccess: payload.accessType === WAN_ACCESS_TYPE.ALWAYS ? 'yes' : 'no',
|
||||
wanport: payload.forwardType === WAN_FORWARD_TYPE.STATIC ? String(payload.port) : '',
|
||||
dynamicRemoteAccessType: getDynamicRemoteAccessType(payload.accessType, payload.forwardType),
|
||||
upnpEnabled: payload.forwardType === WAN_FORWARD_TYPE.UPNP ? 'yes' : 'no',
|
||||
};
|
||||
});
|
||||
@@ -1,20 +1,10 @@
|
||||
import { logDestination, logger } from '@app/core/log.js';
|
||||
import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { stopListeners } from '@app/store/listeners/stop-listeners.js';
|
||||
import { setWanAccess } from '@app/store/modules/config.js';
|
||||
import { writeConfigSync } from '@app/store/sync/config-disk-sync.js';
|
||||
import { MinigraphStatus } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js';
|
||||
import { DynamicRemoteAccessType } from '@app/unraid-api/graph/resolvers/connect/connect.model.js';
|
||||
|
||||
export const shutdownApiEvent = () => {
|
||||
logger.debug('Running shutdown');
|
||||
stopListeners();
|
||||
store.dispatch(setGraphqlConnectionStatus({ status: MinigraphStatus.PRE_INIT, error: null }));
|
||||
if (store.getState().config.remote.dynamicRemoteAccessType !== DynamicRemoteAccessType.DISABLED) {
|
||||
store.dispatch(setWanAccess('no'));
|
||||
}
|
||||
|
||||
logger.debug('Writing final configs');
|
||||
writeConfigSync('flash');
|
||||
writeConfigSync('memory');
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { DNSCheck } from '@app/store/types.js';
|
||||
import { getters, store } from '@app/store/index.js';
|
||||
import { CacheKeys } from '@app/store/types.js';
|
||||
import { type CloudResponse } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js';
|
||||
|
||||
export const getCloudCache = (): CloudResponse | undefined => {
|
||||
const { nodeCache } = getters.cache();
|
||||
return nodeCache.get(CacheKeys.checkCloud);
|
||||
};
|
||||
|
||||
export const getDnsCache = (): DNSCheck | undefined => {
|
||||
const { nodeCache } = getters.cache();
|
||||
return nodeCache.get(CacheKeys.checkDns);
|
||||
};
|
||||
|
||||
export const hasRemoteSubscription = (sha256: string, state = store.getState()): boolean => {
|
||||
return state.remoteGraphQL.subscriptions.some((sub) => sub.sha256 === sha256);
|
||||
};
|
||||
@@ -8,7 +8,7 @@ export const store = configureStore({
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: false,
|
||||
}).prepend(listenerMiddleware.middleware),
|
||||
}).prepend(listenerMiddleware?.middleware ?? []),
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
@@ -16,14 +16,11 @@ export type AppDispatch = typeof store.dispatch;
|
||||
export type ApiStore = typeof store;
|
||||
|
||||
export const getters = {
|
||||
cache: () => store.getState().cache,
|
||||
config: () => store.getState().config,
|
||||
dynamicRemoteAccess: () => store.getState().dynamicRemoteAccess,
|
||||
dynamix: () => store.getState().dynamix,
|
||||
emhttp: () => store.getState().emhttp,
|
||||
minigraph: () => store.getState().minigraph,
|
||||
paths: () => store.getState().paths,
|
||||
registration: () => store.getState().registration,
|
||||
remoteGraphQL: () => store.getState().remoteGraphQL,
|
||||
upnp: () => store.getState().upnp,
|
||||
};
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { isAnyOf } from '@reduxjs/toolkit';
|
||||
|
||||
import { remoteAccessLogger } from '@app/core/log.js';
|
||||
import { RemoteAccessController } from '@app/remoteAccess/remote-access-controller.js';
|
||||
import { type RootState } from '@app/store/index.js';
|
||||
import { startAppListening } from '@app/store/listeners/listener-middleware.js';
|
||||
import { loadConfigFile } from '@app/store/modules/config.js';
|
||||
import { FileLoadStatus } from '@app/store/types.js';
|
||||
import { DynamicRemoteAccessType } from '@app/unraid-api/graph/resolvers/connect/connect.model.js';
|
||||
|
||||
const shouldDynamicRemoteAccessBeEnabled = (state: RootState | null): boolean => {
|
||||
if (
|
||||
state?.config.status !== FileLoadStatus.LOADED ||
|
||||
state?.emhttp.status !== FileLoadStatus.LOADED
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
state.config.remote.dynamicRemoteAccessType &&
|
||||
state.config.remote.dynamicRemoteAccessType !== DynamicRemoteAccessType.DISABLED
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const isStateOrConfigUpdate = isAnyOf(loadConfigFile.fulfilled);
|
||||
|
||||
export const enableDynamicRemoteAccessListener = () =>
|
||||
startAppListening({
|
||||
predicate(action, currentState, previousState) {
|
||||
if (
|
||||
(isStateOrConfigUpdate(action) || !action?.type) &&
|
||||
shouldDynamicRemoteAccessBeEnabled(currentState) !==
|
||||
shouldDynamicRemoteAccessBeEnabled(previousState)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
async effect(_, { getState, dispatch }) {
|
||||
const state = getState();
|
||||
const remoteAccessType = state.config.remote?.dynamicRemoteAccessType;
|
||||
if (!remoteAccessType) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (remoteAccessType === DynamicRemoteAccessType.DISABLED) {
|
||||
remoteAccessLogger.info('[Listener] Disabling Dynamic Remote Access Feature');
|
||||
await RemoteAccessController.instance.stopRemoteAccess({ getState, dispatch });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -6,12 +6,8 @@ import { addListener, createListenerMiddleware } from '@reduxjs/toolkit';
|
||||
import { type AppDispatch, type RootState } from '@app/store/index.js';
|
||||
import { enableArrayEventListener } from '@app/store/listeners/array-event-listener.js';
|
||||
import { enableConfigFileListener } from '@app/store/listeners/config-listener.js';
|
||||
import { enableDynamicRemoteAccessListener } from '@app/store/listeners/dynamic-remote-access-listener.js';
|
||||
import { enableMothershipJobsListener } from '@app/store/listeners/mothership-subscription-listener.js';
|
||||
import { enableServerStateListener } from '@app/store/listeners/server-state-listener.js';
|
||||
import { enableUpnpListener } from '@app/store/listeners/upnp-listener.js';
|
||||
import { enableVersionListener } from '@app/store/listeners/version-listener.js';
|
||||
import { enableWanAccessChangeListener } from '@app/store/listeners/wan-access-change-listener.js';
|
||||
|
||||
export const listenerMiddleware = createListenerMiddleware();
|
||||
|
||||
@@ -25,13 +21,9 @@ export const addAppListener = addListener as TypedAddListener<RootState, AppDisp
|
||||
|
||||
export const startMiddlewareListeners = () => {
|
||||
// Begin listening for events
|
||||
enableMothershipJobsListener();
|
||||
enableConfigFileListener('flash')();
|
||||
enableConfigFileListener('memory')();
|
||||
enableUpnpListener();
|
||||
enableVersionListener();
|
||||
enableDynamicRemoteAccessListener();
|
||||
enableArrayEventListener();
|
||||
enableWanAccessChangeListener();
|
||||
enableServerStateListener();
|
||||
};
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import { minigraphLogger } from '@app/core/log.js';
|
||||
import { setupNewMothershipSubscription } from '@app/mothership/subscribe-to-mothership.js';
|
||||
import { getMothershipConnectionParams } from '@app/mothership/utils/get-mothership-websocket-headers.js';
|
||||
import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status.js';
|
||||
import { startAppListening } from '@app/store/listeners/listener-middleware.js';
|
||||
import { MinigraphStatus } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js';
|
||||
|
||||
export const enableMothershipJobsListener = () =>
|
||||
startAppListening({
|
||||
predicate(action, currentState, previousState) {
|
||||
const newConnectionParams = !isEqual(
|
||||
getMothershipConnectionParams(currentState),
|
||||
getMothershipConnectionParams(previousState)
|
||||
);
|
||||
const apiKey = getMothershipConnectionParams(currentState)?.apiKey;
|
||||
|
||||
// This event happens on first app load, or if a user signs out and signs back in, etc
|
||||
if (newConnectionParams && apiKey) {
|
||||
minigraphLogger.info('Connecting / Reconnecting Mothership Due to Changed Config File');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
setGraphqlConnectionStatus.match(action) &&
|
||||
[MinigraphStatus.PING_FAILURE, MinigraphStatus.PRE_INIT].includes(action.payload.status)
|
||||
) {
|
||||
minigraphLogger.info(
|
||||
'Reconnecting Mothership - PING_FAILURE / PRE_INIT - SetGraphQLConnectionStatus Event'
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
async effect(_, { getState }) {
|
||||
minigraphLogger.trace('Renewing mothership subscription');
|
||||
await setupNewMothershipSubscription(getState());
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user