mirror of
https://github.com/unraid/api.git
synced 2026-02-14 12:08:28 -06:00
chore: extract connect to an API plugin (#1367)
separates Unraid Connect from the API ## Summary by CodeRabbit - **New Features** - Introduced a unified, JSON-schema-based settings system for API configuration and plugin settings, accessible via new GraphQL queries and mutations. - Added modular NestJS plugin architecture for Unraid Connect, including new modules for cloud, remote access, and system/network management. - Added granular connection and remote access state tracking, with new GraphQL types and resolvers for cloud and connection status. - Implemented event-driven and service-based management for SSO users, API keys, and dynamic remote access. - Enhanced UI components and queries to support unified settings and restart detection. - **Improvements** - Refactored configuration and state management to use service-based patterns, replacing direct store access and Redux logic. - Migrated legacy config files to new JSON formats with validation and persistence helpers. - Centralized global dependencies and shared services for plugins and CLI modules. - Improved logging, error handling, and lifecycle management for connections and background jobs. - Updated and expanded documentation for plugin development and settings management. - **Bug Fixes** - Improved handling of missing config files and ensured safe persistence. - Enhanced error reporting and validation in remote access and connection services. - **Removals** - Removed deprecated Redux slices, listeners, and legacy cloud/remote access logic. - Deleted obsolete test files, scripts, and unused code related to the old state/store approach. - **Tests** - Added new unit tests for settings merging, URL resolution, and cloud connectivity checks. - **Style** - Applied consistent formatting, import reorganization, and code style improvements across modules. - **Chores** - Updated build scripts, Dockerfiles, and development environment setup to support new dependencies and workflows. - Expanded .gitignore and configuration files for improved build artifact management.
This commit is contained in:
6
.github/workflows/main.yml
vendored
6
.github/workflows/main.yml
vendored
@@ -162,12 +162,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
|
||||
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
9
api/dev/configs/api.json
Normal file
9
api/dev/configs/api.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "4.8.0",
|
||||
"extraOrigins": [
|
||||
"https://google.com",
|
||||
"https://test.com"
|
||||
],
|
||||
"sandbox": true,
|
||||
"ssoSubIds": []
|
||||
}
|
||||
@@ -1,3 +1,16 @@
|
||||
{
|
||||
"demo": "hello.unraider"
|
||||
"wanaccess": false,
|
||||
"wanport": 0,
|
||||
"upnpEnabled": false,
|
||||
"apikey": "",
|
||||
"localApiKey": "",
|
||||
"email": "",
|
||||
"username": "",
|
||||
"avatar": "",
|
||||
"regWizTime": "",
|
||||
"accesstoken": "",
|
||||
"idtoken": "",
|
||||
"refreshtoken": "",
|
||||
"dynamicRemoteAccessType": "DISABLED",
|
||||
"ssoSubIds": []
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
[api]
|
||||
version="4.8.0"
|
||||
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=""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -14,46 +14,6 @@ directive @usePermissions(
|
||||
possession: AuthPossession
|
||||
) on FIELD_DEFINITION
|
||||
|
||||
type ApiKeyResponse {
|
||||
valid: Boolean!
|
||||
error: String
|
||||
}
|
||||
|
||||
type MinigraphqlResponse {
|
||||
status: MinigraphStatus!
|
||||
timeout: Int
|
||||
error: String
|
||||
}
|
||||
|
||||
enum MinigraphStatus {
|
||||
PRE_INIT
|
||||
CONNECTING
|
||||
CONNECTED
|
||||
PING_FAILURE
|
||||
ERROR_RETRYING
|
||||
}
|
||||
|
||||
type CloudResponse {
|
||||
status: String!
|
||||
ip: String
|
||||
error: String
|
||||
}
|
||||
|
||||
type RelayResponse {
|
||||
status: String!
|
||||
timeout: String
|
||||
error: String
|
||||
}
|
||||
|
||||
type Cloud {
|
||||
error: String
|
||||
apiKey: ApiKeyResponse!
|
||||
relay: RelayResponse
|
||||
minigraphql: MinigraphqlResponse!
|
||||
cloud: CloudResponse!
|
||||
allowedOrigins: [String!]!
|
||||
}
|
||||
|
||||
type Capacity {
|
||||
"""Free capacity"""
|
||||
free: String!
|
||||
@@ -287,126 +247,6 @@ A field whose value conforms to the standard URL format as specified in RFC3986:
|
||||
"""
|
||||
scalar URL
|
||||
|
||||
type RemoteAccess {
|
||||
"""The type of WAN access used for Remote Access"""
|
||||
accessType: WAN_ACCESS_TYPE!
|
||||
|
||||
"""The type of port forwarding used for Remote Access"""
|
||||
forwardType: WAN_FORWARD_TYPE
|
||||
|
||||
"""The port used for Remote Access"""
|
||||
port: Int
|
||||
}
|
||||
|
||||
enum WAN_ACCESS_TYPE {
|
||||
DYNAMIC
|
||||
ALWAYS
|
||||
DISABLED
|
||||
}
|
||||
|
||||
enum WAN_FORWARD_TYPE {
|
||||
UPNP
|
||||
STATIC
|
||||
}
|
||||
|
||||
type DynamicRemoteAccessStatus {
|
||||
"""The type of dynamic remote access that is enabled"""
|
||||
enabledType: DynamicRemoteAccessType!
|
||||
|
||||
"""The type of dynamic remote access that is currently running"""
|
||||
runningType: DynamicRemoteAccessType!
|
||||
|
||||
"""Any error message associated with the dynamic remote access"""
|
||||
error: String
|
||||
}
|
||||
|
||||
enum DynamicRemoteAccessType {
|
||||
STATIC
|
||||
UPNP
|
||||
DISABLED
|
||||
}
|
||||
|
||||
type ConnectSettingsValues {
|
||||
"""
|
||||
If true, the GraphQL sandbox is enabled and available at /graphql. If false, the GraphQL sandbox is disabled and only the production API will be available.
|
||||
"""
|
||||
sandbox: Boolean!
|
||||
|
||||
"""A list of origins allowed to interact with the API"""
|
||||
extraOrigins: [String!]!
|
||||
|
||||
"""The type of WAN access used for Remote Access"""
|
||||
accessType: WAN_ACCESS_TYPE!
|
||||
|
||||
"""The type of port forwarding used for Remote Access"""
|
||||
forwardType: WAN_FORWARD_TYPE
|
||||
|
||||
"""The port used for Remote Access"""
|
||||
port: Int
|
||||
|
||||
"""A list of Unique Unraid Account ID's"""
|
||||
ssoUserIds: [String!]!
|
||||
}
|
||||
|
||||
type ConnectSettings implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""The data schema for the Connect settings"""
|
||||
dataSchema: JSON!
|
||||
|
||||
"""The UI schema for the Connect settings"""
|
||||
uiSchema: JSON!
|
||||
|
||||
"""The values for the Connect settings"""
|
||||
values: ConnectSettingsValues!
|
||||
}
|
||||
|
||||
"""
|
||||
The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
|
||||
"""
|
||||
scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf")
|
||||
|
||||
type Connect implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""The status of dynamic remote access"""
|
||||
dynamicRemoteAccess: DynamicRemoteAccessStatus!
|
||||
|
||||
"""The settings for the Connect instance"""
|
||||
settings: ConnectSettings!
|
||||
}
|
||||
|
||||
type Network implements Node {
|
||||
id: PrefixedID!
|
||||
accessUrls: [AccessUrl!]
|
||||
}
|
||||
|
||||
type ProfileModel implements Node {
|
||||
id: PrefixedID!
|
||||
username: String!
|
||||
url: String!
|
||||
avatar: String!
|
||||
}
|
||||
|
||||
type Server implements Node {
|
||||
id: PrefixedID!
|
||||
owner: ProfileModel!
|
||||
guid: String!
|
||||
apikey: String!
|
||||
name: String!
|
||||
status: ServerStatus!
|
||||
wanip: String!
|
||||
lanip: String!
|
||||
localurl: String!
|
||||
remoteurl: String!
|
||||
}
|
||||
|
||||
enum ServerStatus {
|
||||
ONLINE
|
||||
OFFLINE
|
||||
NEVER_CONNECTED
|
||||
}
|
||||
|
||||
type DiskPartition {
|
||||
"""The name of the partition"""
|
||||
name: String!
|
||||
@@ -798,6 +638,7 @@ type ApiKey implements Node {
|
||||
"""Available roles for API keys and users"""
|
||||
enum Role {
|
||||
ADMIN
|
||||
USER
|
||||
CONNECT
|
||||
GUEST
|
||||
}
|
||||
@@ -812,6 +653,46 @@ type ApiKeyWithSecret implements Node {
|
||||
key: String!
|
||||
}
|
||||
|
||||
type RCloneDrive {
|
||||
"""Provider name"""
|
||||
name: String!
|
||||
|
||||
"""Provider options and configuration schema"""
|
||||
options: JSON!
|
||||
}
|
||||
|
||||
"""
|
||||
The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
|
||||
"""
|
||||
scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf")
|
||||
|
||||
type RCloneBackupConfigForm {
|
||||
id: ID!
|
||||
dataSchema: JSON!
|
||||
uiSchema: JSON!
|
||||
}
|
||||
|
||||
type RCloneBackupSettings {
|
||||
configForm(formOptions: RCloneConfigFormInput): RCloneBackupConfigForm!
|
||||
drives: [RCloneDrive!]!
|
||||
remotes: [RCloneRemote!]!
|
||||
}
|
||||
|
||||
input RCloneConfigFormInput {
|
||||
providerType: String
|
||||
showAdvanced: Boolean = false
|
||||
parameters: JSON
|
||||
}
|
||||
|
||||
type RCloneRemote {
|
||||
name: String!
|
||||
type: String!
|
||||
parameters: JSON!
|
||||
|
||||
"""Complete remote configuration"""
|
||||
config: JSON!
|
||||
}
|
||||
|
||||
type ArrayMutations {
|
||||
"""Set array state"""
|
||||
setState(input: ArrayStateInput!): UnraidArray!
|
||||
@@ -1364,41 +1245,6 @@ type FlashBackupStatus {
|
||||
jobId: String
|
||||
}
|
||||
|
||||
type RCloneDrive {
|
||||
"""Provider name"""
|
||||
name: String!
|
||||
|
||||
"""Provider options and configuration schema"""
|
||||
options: JSON!
|
||||
}
|
||||
|
||||
type RCloneBackupConfigForm {
|
||||
id: ID!
|
||||
dataSchema: JSON!
|
||||
uiSchema: JSON!
|
||||
}
|
||||
|
||||
type RCloneBackupSettings {
|
||||
configForm(formOptions: RCloneConfigFormInput): RCloneBackupConfigForm!
|
||||
drives: [RCloneDrive!]!
|
||||
remotes: [RCloneRemote!]!
|
||||
}
|
||||
|
||||
input RCloneConfigFormInput {
|
||||
providerType: String
|
||||
showAdvanced: Boolean = false
|
||||
parameters: JSON
|
||||
}
|
||||
|
||||
type RCloneRemote {
|
||||
name: String!
|
||||
type: String!
|
||||
parameters: JSON!
|
||||
|
||||
"""Complete remote configuration"""
|
||||
config: JSON!
|
||||
}
|
||||
|
||||
type Flash implements Node {
|
||||
id: PrefixedID!
|
||||
guid: String!
|
||||
@@ -1494,6 +1340,70 @@ type Owner {
|
||||
avatar: String!
|
||||
}
|
||||
|
||||
type ProfileModel implements Node {
|
||||
id: PrefixedID!
|
||||
username: String!
|
||||
url: String!
|
||||
avatar: String!
|
||||
}
|
||||
|
||||
type Server implements Node {
|
||||
id: PrefixedID!
|
||||
owner: ProfileModel!
|
||||
guid: String!
|
||||
apikey: String!
|
||||
name: String!
|
||||
status: ServerStatus!
|
||||
wanip: String!
|
||||
lanip: String!
|
||||
localurl: String!
|
||||
remoteurl: String!
|
||||
}
|
||||
|
||||
enum ServerStatus {
|
||||
ONLINE
|
||||
OFFLINE
|
||||
NEVER_CONNECTED
|
||||
}
|
||||
|
||||
type ApiConfig {
|
||||
version: String!
|
||||
extraOrigins: [String!]!
|
||||
sandbox: Boolean
|
||||
ssoSubIds: [String!]!
|
||||
}
|
||||
|
||||
type UnifiedSettings implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""The data schema for the settings"""
|
||||
dataSchema: JSON!
|
||||
|
||||
"""The UI schema for the settings"""
|
||||
uiSchema: JSON!
|
||||
|
||||
"""The current values of the settings"""
|
||||
values: JSON!
|
||||
}
|
||||
|
||||
type UpdateSettingsResponse {
|
||||
"""Whether a restart is required for the changes to take effect"""
|
||||
restartRequired: Boolean!
|
||||
|
||||
"""The updated settings values"""
|
||||
values: JSON!
|
||||
}
|
||||
|
||||
type Settings implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""A view of all settings"""
|
||||
unified: UnifiedSettings!
|
||||
|
||||
"""The API setting values"""
|
||||
api: ApiConfig!
|
||||
}
|
||||
|
||||
type VmDomain implements Node {
|
||||
"""The unique identifier for the vm (uuid)"""
|
||||
id: PrefixedID!
|
||||
@@ -1554,11 +1464,151 @@ type UserAccount implements Node {
|
||||
permissions: [Permission!]
|
||||
}
|
||||
|
||||
type AccessUrlObject {
|
||||
ipv4: String
|
||||
ipv6: String
|
||||
type: URL_TYPE!
|
||||
name: String
|
||||
}
|
||||
|
||||
type RemoteAccess {
|
||||
"""The type of WAN access used for Remote Access"""
|
||||
accessType: WAN_ACCESS_TYPE!
|
||||
|
||||
"""The type of port forwarding used for Remote Access"""
|
||||
forwardType: WAN_FORWARD_TYPE
|
||||
|
||||
"""The port used for Remote Access"""
|
||||
port: Int
|
||||
}
|
||||
|
||||
enum WAN_ACCESS_TYPE {
|
||||
DYNAMIC
|
||||
ALWAYS
|
||||
DISABLED
|
||||
}
|
||||
|
||||
enum WAN_FORWARD_TYPE {
|
||||
UPNP
|
||||
STATIC
|
||||
}
|
||||
|
||||
type DynamicRemoteAccessStatus {
|
||||
"""The type of dynamic remote access that is enabled"""
|
||||
enabledType: DynamicRemoteAccessType!
|
||||
|
||||
"""The type of dynamic remote access that is currently running"""
|
||||
runningType: DynamicRemoteAccessType!
|
||||
|
||||
"""Any error message associated with the dynamic remote access"""
|
||||
error: String
|
||||
}
|
||||
|
||||
enum DynamicRemoteAccessType {
|
||||
STATIC
|
||||
UPNP
|
||||
DISABLED
|
||||
}
|
||||
|
||||
type ConnectSettingsValues {
|
||||
"""The type of WAN access used for Remote Access"""
|
||||
accessType: WAN_ACCESS_TYPE!
|
||||
|
||||
"""The type of port forwarding used for Remote Access"""
|
||||
forwardType: WAN_FORWARD_TYPE
|
||||
|
||||
"""The port used for Remote Access"""
|
||||
port: Int
|
||||
}
|
||||
|
||||
type ConnectSettings implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""The data schema for the Connect settings"""
|
||||
dataSchema: JSON!
|
||||
|
||||
"""The UI schema for the Connect settings"""
|
||||
uiSchema: JSON!
|
||||
|
||||
"""The values for the Connect settings"""
|
||||
values: ConnectSettingsValues!
|
||||
}
|
||||
|
||||
type Connect implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""The status of dynamic remote access"""
|
||||
dynamicRemoteAccess: DynamicRemoteAccessStatus!
|
||||
|
||||
"""The settings for the Connect instance"""
|
||||
settings: ConnectSettings!
|
||||
}
|
||||
|
||||
type Network implements Node {
|
||||
id: PrefixedID!
|
||||
accessUrls: [AccessUrl!]
|
||||
}
|
||||
|
||||
type ApiKeyResponse {
|
||||
valid: Boolean!
|
||||
error: String
|
||||
}
|
||||
|
||||
type MinigraphqlResponse {
|
||||
status: MinigraphStatus!
|
||||
timeout: Int
|
||||
error: String
|
||||
}
|
||||
|
||||
"""The status of the minigraph"""
|
||||
enum MinigraphStatus {
|
||||
PRE_INIT
|
||||
CONNECTING
|
||||
CONNECTED
|
||||
PING_FAILURE
|
||||
ERROR_RETRYING
|
||||
}
|
||||
|
||||
type CloudResponse {
|
||||
status: String!
|
||||
ip: String
|
||||
error: String
|
||||
}
|
||||
|
||||
type RelayResponse {
|
||||
status: String!
|
||||
timeout: String
|
||||
error: String
|
||||
}
|
||||
|
||||
type Cloud {
|
||||
error: String
|
||||
apiKey: ApiKeyResponse!
|
||||
relay: RelayResponse
|
||||
minigraphql: MinigraphqlResponse!
|
||||
cloud: CloudResponse!
|
||||
allowedOrigins: [String!]!
|
||||
}
|
||||
|
||||
input AccessUrlObjectInput {
|
||||
ipv4: String
|
||||
ipv6: String
|
||||
type: URL_TYPE!
|
||||
name: String
|
||||
}
|
||||
|
||||
"\n### Description:\n\nID scalar type that prefixes the underlying ID with the server identifier on output and strips it on input.\n\nWe use this scalar type to ensure that the ID is unique across all servers, allowing the same underlying resource ID to be used across different server instances.\n\n#### Input Behavior:\n\nWhen providing an ID as input (e.g., in arguments or input objects), the server identifier prefix ('<serverId>:') is optional.\n\n- If the prefix is present (e.g., '123:456'), it will be automatically stripped, and only the underlying ID ('456') will be used internally.\n- If the prefix is absent (e.g., '456'), the ID will be used as-is.\n\nThis makes it flexible for clients, as they don't strictly need to know or provide the server ID.\n\n#### Output Behavior:\n\nWhen an ID is returned in the response (output), it will *always* be prefixed with the current server's unique identifier (e.g., '123:456').\n\n#### Example:\n\nNote: The server identifier is '123' in this example.\n\n##### Input (Prefix Optional):\n```graphql\n# Both of these are valid inputs resolving to internal ID '456'\n{\n someQuery(id: \"123:456\") { ... }\n anotherQuery(id: \"456\") { ... }\n}\n```\n\n##### Output (Prefix Always Added):\n```graphql\n# Assuming internal ID is '456'\n{\n \"data\": {\n \"someResource\": {\n \"id\": \"123:456\" \n }\n }\n}\n```\n "
|
||||
scalar PrefixedID
|
||||
|
||||
type Query {
|
||||
cloud: Cloud!
|
||||
apiKeys: [ApiKey!]!
|
||||
apiKey(id: PrefixedID!): ApiKey
|
||||
|
||||
"""All possible roles for API keys"""
|
||||
apiKeyPossibleRoles: [Role!]!
|
||||
|
||||
"""All possible permissions for API keys"""
|
||||
apiKeyPossiblePermissions: [Permission!]!
|
||||
config: Config!
|
||||
display: Display!
|
||||
flash: Flash!
|
||||
@@ -1566,7 +1616,6 @@ type Query {
|
||||
logFiles: [LogFile!]!
|
||||
logFile(path: String!, lines: Int, startLine: Int): LogFileContent!
|
||||
me: UserAccount!
|
||||
network: Network!
|
||||
|
||||
"""Get all notifications"""
|
||||
notifications: Notifications!
|
||||
@@ -1583,17 +1632,6 @@ type Query {
|
||||
vms: Vms!
|
||||
parityHistory: [ParityCheck!]!
|
||||
array: UnraidArray!
|
||||
apiKeys: [ApiKey!]!
|
||||
apiKey(id: PrefixedID!): ApiKey
|
||||
|
||||
"""All possible roles for API keys"""
|
||||
apiKeyPossibleRoles: [Role!]!
|
||||
|
||||
"""All possible permissions for API keys"""
|
||||
apiKeyPossiblePermissions: [Permission!]!
|
||||
connect: Connect!
|
||||
remoteAccess: RemoteAccess!
|
||||
extraAllowedOrigins: [String!]!
|
||||
customization: Customization
|
||||
publicPartnerInfo: PublicPartnerInfo
|
||||
publicTheme: Theme!
|
||||
@@ -1601,8 +1639,11 @@ type Query {
|
||||
disks: [Disk!]!
|
||||
disk(id: PrefixedID!): Disk!
|
||||
rclone: RCloneBackupSettings!
|
||||
health: String!
|
||||
getDemo: String!
|
||||
settings: Settings!
|
||||
remoteAccess: RemoteAccess!
|
||||
connect: Connect!
|
||||
network: Network!
|
||||
cloud: Cloud!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
@@ -1631,16 +1672,15 @@ type Mutation {
|
||||
parityCheck: ParityCheckMutations!
|
||||
apiKey: ApiKeyMutations!
|
||||
rclone: RCloneMutations!
|
||||
updateApiSettings(input: ApiSettingsInput!): ConnectSettingsValues!
|
||||
connectSignIn(input: ConnectSignInInput!): Boolean!
|
||||
connectSignOut: Boolean!
|
||||
setupRemoteAccess(input: SetupRemoteAccessInput!): Boolean!
|
||||
setAdditionalAllowedOrigins(input: AllowedOriginInput!): [String!]!
|
||||
enableDynamicRemoteAccess(input: EnableDynamicRemoteAccessInput!): Boolean!
|
||||
|
||||
"""Initiates a flash drive backup using a configured remote."""
|
||||
initiateFlashBackup(input: InitiateFlashBackupInput!): FlashBackupStatus!
|
||||
setDemo: String!
|
||||
updateSettings(input: JSON!): UpdateSettingsResponse!
|
||||
updateApiSettings(input: ConnectSettingsInput!): ConnectSettingsValues!
|
||||
connectSignIn(input: ConnectSignInInput!): Boolean!
|
||||
connectSignOut: Boolean!
|
||||
setupRemoteAccess(input: SetupRemoteAccessInput!): Boolean!
|
||||
enableDynamicRemoteAccess(input: EnableDynamicRemoteAccessInput!): Boolean!
|
||||
}
|
||||
|
||||
input NotificationData {
|
||||
@@ -1651,15 +1691,23 @@ input NotificationData {
|
||||
link: String
|
||||
}
|
||||
|
||||
input ApiSettingsInput {
|
||||
"""
|
||||
If true, the GraphQL sandbox will be enabled and available at /graphql. If false, the GraphQL sandbox will be disabled and only the production API will be available.
|
||||
"""
|
||||
sandbox: Boolean
|
||||
input InitiateFlashBackupInput {
|
||||
"""The name of the remote configuration to use for the backup."""
|
||||
remoteName: String!
|
||||
|
||||
"""A list of origins allowed to interact with the API"""
|
||||
extraOrigins: [String!]
|
||||
"""Source path to backup (typically the flash drive)."""
|
||||
sourcePath: String!
|
||||
|
||||
"""Destination path on the remote."""
|
||||
destinationPath: String!
|
||||
|
||||
"""
|
||||
Additional options for the backup operation, such as --dry-run or --transfers.
|
||||
"""
|
||||
options: JSON
|
||||
}
|
||||
|
||||
input ConnectSettingsInput {
|
||||
"""The type of WAN access to use for Remote Access"""
|
||||
accessType: WAN_ACCESS_TYPE
|
||||
|
||||
@@ -1670,9 +1718,6 @@ input ApiSettingsInput {
|
||||
The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType. Ignored if accessType is DISABLED or forwardType is UPNP.
|
||||
"""
|
||||
port: Int
|
||||
|
||||
"""A list of Unique Unraid Account ID's"""
|
||||
ssoUserIds: [String!]
|
||||
}
|
||||
|
||||
input ConnectSignInInput {
|
||||
@@ -1716,11 +1761,6 @@ input SetupRemoteAccessInput {
|
||||
port: Int
|
||||
}
|
||||
|
||||
input AllowedOriginInput {
|
||||
"""A list of origins allowed to interact with the API"""
|
||||
origins: [String!]!
|
||||
}
|
||||
|
||||
input EnableDynamicRemoteAccessInput {
|
||||
"""The AccessURL Input for dynamic remote access"""
|
||||
url: AccessUrlInput!
|
||||
@@ -1736,22 +1776,6 @@ input AccessUrlInput {
|
||||
ipv6: URL
|
||||
}
|
||||
|
||||
input InitiateFlashBackupInput {
|
||||
"""The name of the remote configuration to use for the backup."""
|
||||
remoteName: String!
|
||||
|
||||
"""Source path to backup (typically the flash drive)."""
|
||||
sourcePath: String!
|
||||
|
||||
"""Destination path on the remote."""
|
||||
destinationPath: String!
|
||||
|
||||
"""
|
||||
Additional options for the backup operation, such as --dry-run or --transfers.
|
||||
"""
|
||||
options: JSON
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
displaySubscription: Display!
|
||||
infoSubscription: Info!
|
||||
|
||||
@@ -16,11 +16,12 @@
|
||||
"// 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",
|
||||
"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",
|
||||
@@ -67,6 +68,7 @@
|
||||
"@nestjs/common": "^11.0.11",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.11",
|
||||
"@nestjs/event-emitter": "^3.0.1",
|
||||
"@nestjs/graphql": "^13.0.3",
|
||||
"@nestjs/passport": "^11.0.0",
|
||||
"@nestjs/platform-fastify": "^11.0.11",
|
||||
@@ -76,6 +78,7 @@
|
||||
"@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",
|
||||
@@ -201,7 +204,7 @@
|
||||
"prettier": "^3.5.2",
|
||||
"rollup-plugin-node-externals": "^8.0.0",
|
||||
"standard-version": "^9.5.0",
|
||||
"tsx": "^4.19.2",
|
||||
"tsx": "^4.19.3",
|
||||
"type-fest": "^4.37.0",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.13.0",
|
||||
|
||||
@@ -1,17 +1,59 @@
|
||||
#!/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';
|
||||
import { $, cd } from 'zx';
|
||||
|
||||
import { getDeploymentVersion } from '@app/../scripts/get-deployment-version.js';
|
||||
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,6 +105,21 @@ try {
|
||||
|
||||
await writeFile('package.json', JSON.stringify(parsedPackageJson, null, 4));
|
||||
|
||||
/** 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 });
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const compressionLevel = process.env.WATCH_MODE ? '-1' : '-5';
|
||||
await $`XZ_OPT=${compressionLevel} tar -cJf packed-node-modules.tar.xz node_modules`;
|
||||
// Create a subdirectory for the node modules archive
|
||||
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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:'
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
// });
|
||||
// });
|
||||
});
|
||||
|
||||
@@ -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,14 +1,13 @@
|
||||
import { pino } from 'pino';
|
||||
import pretty from 'pino-pretty';
|
||||
|
||||
import { API_VERSION, 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();
|
||||
|
||||
@@ -43,6 +42,11 @@ export const logger = pino(
|
||||
'*.Secret',
|
||||
'*.Token',
|
||||
'*.Key',
|
||||
'*.apikey',
|
||||
'*.localApiKey',
|
||||
'*.accesstoken',
|
||||
'*.idtoken',
|
||||
'*.refreshtoken',
|
||||
],
|
||||
censor: '***REDACTED***',
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
@@ -94,5 +97,8 @@ 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 PM2_PATH = join(import.meta.dirname, '../../', 'node_modules', 'pm2', 'bin', 'pm2');
|
||||
export const ECOSYSTEM_PATH = join(import.meta.dirname, '../../', 'ecosystem.config.json');
|
||||
|
||||
export const PATHS_CONFIG_MODULES =
|
||||
process.env.PATHS_CONFIG_MODULES ?? '/usr/local/unraid-api/config/modules';
|
||||
|
||||
@@ -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,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));
|
||||
};
|
||||
@@ -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';
|
||||
@@ -43,6 +43,22 @@ export const viteNodeApp = async () => {
|
||||
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);
|
||||
|
||||
@@ -71,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());
|
||||
},
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import { mothershipLogger } from '@app/core/log.js';
|
||||
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { getServers } from '@app/graphql/schema/utils.js';
|
||||
import { isAPIStateDataFullyLoaded } from '@app/mothership/graphql-client.js';
|
||||
import { startAppListening } from '@app/store/listeners/listener-middleware.js';
|
||||
import { FileLoadStatus } from '@app/store/types.js';
|
||||
|
||||
export const enableServerStateListener = () =>
|
||||
startAppListening({
|
||||
predicate: (_, currState, prevState) => {
|
||||
if (
|
||||
currState.config.status === FileLoadStatus.LOADED &&
|
||||
currState.emhttp.status === FileLoadStatus.LOADED
|
||||
) {
|
||||
if (
|
||||
prevState.minigraph.status !== currState.minigraph.status ||
|
||||
!isEqual(prevState.config.remote, currState.config.remote)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
async effect(_, { getState }) {
|
||||
if (isAPIStateDataFullyLoaded(getState())) {
|
||||
const servers = getServers(getState);
|
||||
mothershipLogger.trace('Got local server state', servers);
|
||||
if (servers.length > 0) {
|
||||
// Publish owner event
|
||||
await pubsub.publish(PUBSUB_CHANNEL.OWNER, {
|
||||
owner: servers[0].owner,
|
||||
});
|
||||
|
||||
// Publish servers event
|
||||
await pubsub.publish(PUBSUB_CHANNEL.SERVERS, {
|
||||
servers: servers,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { isAnyOf } from '@reduxjs/toolkit';
|
||||
|
||||
import { upnpLogger } from '@app/core/log.js';
|
||||
import { setupRemoteAccessThunk } from '@app/store/actions/setup-remote-access.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';
|
||||
@@ -9,6 +8,7 @@ import { loadSingleStateFile, loadStateFiles } from '@app/store/modules/emhttp.j
|
||||
import { disableUpnp, enableUpnp } from '@app/store/modules/upnp.js';
|
||||
import { FileLoadStatus } from '@app/store/types.js';
|
||||
|
||||
// FLAG for review: make sure we replace this
|
||||
const shouldUpnpBeEnabled = (state: RootState | null): boolean => {
|
||||
if (
|
||||
state?.config.status !== FileLoadStatus.LOADED ||
|
||||
@@ -26,8 +26,8 @@ const shouldUpnpBeEnabled = (state: RootState | null): boolean => {
|
||||
const isStateOrConfigUpdate = isAnyOf(
|
||||
loadConfigFile.fulfilled,
|
||||
loadSingleStateFile.fulfilled,
|
||||
loadStateFiles.fulfilled,
|
||||
setupRemoteAccessThunk.fulfilled
|
||||
loadStateFiles.fulfilled
|
||||
// setupRemoteAccessThunk.fulfilled
|
||||
);
|
||||
|
||||
export const enableUpnpListener = () =>
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { remoteAccessLogger } from '@app/core/log.js';
|
||||
import { reloadNginxAndUpdateDNS } from '@app/store/actions/reload-nginx-and-update-dns.js';
|
||||
import { startAppListening } from '@app/store/listeners/listener-middleware.js';
|
||||
import { loadConfigFile } from '@app/store/modules/config.js';
|
||||
|
||||
export const enableWanAccessChangeListener = () =>
|
||||
startAppListening({
|
||||
predicate: (action, state, previousState) => {
|
||||
if (
|
||||
action.type === loadConfigFile.fulfilled.type &&
|
||||
previousState.config.remote.wanaccess !== '' &&
|
||||
state.config.remote.wanaccess !== previousState.config.remote.wanaccess
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
async effect(_, { dispatch }) {
|
||||
remoteAccessLogger.info('Wan access value changed, reloading Nginx and Calling Update DNS');
|
||||
await dispatch(reloadNginxAndUpdateDNS());
|
||||
},
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import NodeCache from 'node-cache';
|
||||
|
||||
import type { DNSCheck } from '@app/store/types.js';
|
||||
import { ONE_HOUR_SECS } from '@app/consts.js';
|
||||
import { CacheKeys } from '@app/store/types.js';
|
||||
import { CloudResponse } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js';
|
||||
|
||||
const initialState: {
|
||||
nodeCache: NodeCache;
|
||||
} = {
|
||||
nodeCache: new NodeCache(),
|
||||
};
|
||||
|
||||
export const cache = createSlice({
|
||||
name: 'cache',
|
||||
initialState,
|
||||
reducers: {
|
||||
setCache(state, action: PayloadAction<{ key: string; value: unknown; ttl: number }>) {
|
||||
state.nodeCache.set(action.payload.key, action.payload.value, action.payload.ttl);
|
||||
},
|
||||
setCloudCheck(state, action: PayloadAction<CloudResponse>) {
|
||||
const ttl = action.payload.error === null ? ONE_HOUR_SECS * 4 : 60 * 5; // 4 hours for a success, 5 minutes for a failure
|
||||
state.nodeCache.set(CacheKeys.checkCloud, action.payload, ttl);
|
||||
},
|
||||
setDNSCheck(state, action: PayloadAction<DNSCheck>) {
|
||||
// Cache permanently if we set this option
|
||||
const customTTL = !action.payload.error && action.payload.ttl ? action.payload.ttl : null;
|
||||
|
||||
const ttl = (customTTL ?? action.payload.error === null) ? ONE_HOUR_SECS * 12 : 60 * 15; // 12 hours for a success, 15 minutes for a failure
|
||||
state.nodeCache.set(CacheKeys.checkDns, action.payload, ttl);
|
||||
},
|
||||
clearKey(state, action: PayloadAction<CacheKeys>) {
|
||||
state.nodeCache.del(action.payload);
|
||||
},
|
||||
flushCache(state) {
|
||||
state.nodeCache.flushAll();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setCache, setCloudCheck, setDNSCheck, clearKey, flushCache } = cache.actions;
|
||||
@@ -12,13 +12,13 @@ import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-seria
|
||||
import { parseConfig } from '@app/core/utils/misc/parse-config.js';
|
||||
import { NODE_ENV } from '@app/environment.js';
|
||||
import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status.js';
|
||||
import { setupRemoteAccessThunk } from '@app/store/actions/setup-remote-access.js';
|
||||
// import { setupRemoteAccessThunk } from '@app/store/actions/setup-remote-access.js';
|
||||
import { type RootState } from '@app/store/index.js';
|
||||
import { FileLoadStatus } from '@app/store/types.js';
|
||||
import { RecursivePartial } from '@app/types/index.js';
|
||||
import { type MyServersConfig, type MyServersConfigMemory } from '@app/types/my-servers-config.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';
|
||||
// import { DynamicRemoteAccessType } from '@app/unraid-api/graph/resolvers/connect/connect.model.js';
|
||||
import { Owner } from '@app/unraid-api/graph/resolvers/owner/owner.model.js';
|
||||
|
||||
export type SliceState = {
|
||||
@@ -43,7 +43,7 @@ export const initialState: SliceState = {
|
||||
idtoken: '',
|
||||
refreshtoken: '',
|
||||
allowedOrigins: '',
|
||||
dynamicRemoteAccessType: DynamicRemoteAccessType.DISABLED,
|
||||
dynamicRemoteAccessType: 'DISABLED',
|
||||
ssoSubIds: '',
|
||||
},
|
||||
local: {
|
||||
@@ -78,25 +78,7 @@ export const loginUser = createAsyncThunk<
|
||||
export const logoutUser = createAsyncThunk<void, { reason?: string }, { state: RootState }>(
|
||||
'config/logout-user',
|
||||
async ({ reason }) => {
|
||||
logger.info('Logging out user: %s', reason ?? 'No reason provided');
|
||||
const { pubsub, PUBSUB_CHANNEL } = await import('@app/core/pubsub.js');
|
||||
const { stopPingTimeoutJobs } = await import('@app/mothership/jobs/ping-timeout-jobs.js');
|
||||
const { GraphQLClient } = await import('@app/mothership/graphql-client.js');
|
||||
|
||||
// Publish to servers endpoint
|
||||
await pubsub.publish(PUBSUB_CHANNEL.SERVERS, {
|
||||
servers: [],
|
||||
});
|
||||
|
||||
const owner: Owner = {
|
||||
username: 'root',
|
||||
url: '',
|
||||
avatar: '',
|
||||
};
|
||||
// Publish to owner endpoint
|
||||
await pubsub.publish(PUBSUB_CHANNEL.OWNER, { owner });
|
||||
stopPingTimeoutJobs();
|
||||
await GraphQLClient.clearInstance();
|
||||
logger.warn('invoked legacy logoutUser. no action taken.');
|
||||
}
|
||||
);
|
||||
|
||||
@@ -212,29 +194,29 @@ export const config = createSlice({
|
||||
setWanAccess(state, action: PayloadAction<'yes' | 'no'>) {
|
||||
state.remote.wanaccess = action.payload;
|
||||
},
|
||||
addSsoUser(state, action: PayloadAction<string>) {
|
||||
// First check if state already has ID, otherwise append it
|
||||
if (state.remote.ssoSubIds.includes(action.payload)) {
|
||||
return;
|
||||
}
|
||||
const stateAsArray = state.remote.ssoSubIds.split(',').filter((id) => id !== '');
|
||||
stateAsArray.push(action.payload);
|
||||
state.remote.ssoSubIds = stateAsArray.join(',');
|
||||
},
|
||||
// addSsoUser(state, action: PayloadAction<string>) {
|
||||
// // First check if state already has ID, otherwise append it
|
||||
// if (state.remote.ssoSubIds.includes(action.payload)) {
|
||||
// return;
|
||||
// }
|
||||
// const stateAsArray = state.remote.ssoSubIds.split(',').filter((id) => id !== '');
|
||||
// stateAsArray.push(action.payload);
|
||||
// state.remote.ssoSubIds = stateAsArray.join(',');
|
||||
// },
|
||||
setSsoUsers(state, action: PayloadAction<string[]>) {
|
||||
state.remote.ssoSubIds = action.payload.filter((id) => id).join(',');
|
||||
},
|
||||
removeSsoUser(state, action: PayloadAction<string | null>) {
|
||||
if (action.payload === null) {
|
||||
state.remote.ssoSubIds = '';
|
||||
return;
|
||||
}
|
||||
if (!state.remote.ssoSubIds.includes(action.payload)) {
|
||||
return;
|
||||
}
|
||||
const stateAsArray = state.remote.ssoSubIds.split(',').filter((id) => id !== action.payload);
|
||||
state.remote.ssoSubIds = stateAsArray.join(',');
|
||||
},
|
||||
// removeSsoUser(state, action: PayloadAction<string | null>) {
|
||||
// if (action.payload === null) {
|
||||
// state.remote.ssoSubIds = '';
|
||||
// return;
|
||||
// }
|
||||
// if (!state.remote.ssoSubIds.includes(action.payload)) {
|
||||
// return;
|
||||
// }
|
||||
// const stateAsArray = state.remote.ssoSubIds.split(',').filter((id) => id !== action.payload);
|
||||
// state.remote.ssoSubIds = stateAsArray.join(',');
|
||||
// },
|
||||
setLocalApiKey(state, action: PayloadAction<string | null>) {
|
||||
state.remote.localApiKey = action.payload ?? '';
|
||||
},
|
||||
@@ -291,7 +273,7 @@ export const config = createSlice({
|
||||
idtoken: '',
|
||||
accessToken: '',
|
||||
refreshToken: '',
|
||||
dynamicRemoteAccessType: DynamicRemoteAccessType.DISABLED,
|
||||
// dynamicRemoteAccessType: DynamicRemoteAccessType.DISABLED,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -300,18 +282,18 @@ export const config = createSlice({
|
||||
state.connectionStatus.minigraph = action.payload.status;
|
||||
});
|
||||
|
||||
builder.addCase(setupRemoteAccessThunk.fulfilled, (state, action) => {
|
||||
state.remote.wanaccess = action.payload.wanaccess;
|
||||
state.remote.dynamicRemoteAccessType = action.payload.dynamicRemoteAccessType;
|
||||
state.remote.wanport = action.payload.wanport;
|
||||
state.remote.upnpEnabled = action.payload.upnpEnabled;
|
||||
});
|
||||
// builder.addCase(setupRemoteAccessThunk.fulfilled, (state, action) => {
|
||||
// state.remote.wanaccess = action.payload.wanaccess;
|
||||
// state.remote.dynamicRemoteAccessType = action.payload.dynamicRemoteAccessType;
|
||||
// state.remote.wanport = action.payload.wanport;
|
||||
// state.remote.upnpEnabled = action.payload.upnpEnabled;
|
||||
// });
|
||||
},
|
||||
});
|
||||
const { actions, reducer } = config;
|
||||
|
||||
export const {
|
||||
addSsoUser,
|
||||
// addSsoUser,
|
||||
setSsoUsers,
|
||||
updateUserConfig,
|
||||
updateAccessTokens,
|
||||
@@ -319,7 +301,7 @@ export const {
|
||||
setUpnpState,
|
||||
setWanPortToValue,
|
||||
setWanAccess,
|
||||
removeSsoUser,
|
||||
// removeSsoUser,
|
||||
setLocalApiKey,
|
||||
} = actions;
|
||||
|
||||
@@ -327,7 +309,7 @@ export const {
|
||||
* Actions that should trigger a flash write
|
||||
*/
|
||||
export const configUpdateActionsFlash = isAnyOf(
|
||||
addSsoUser,
|
||||
// addSsoUser,
|
||||
setSsoUsers,
|
||||
updateUserConfig,
|
||||
updateAccessTokens,
|
||||
@@ -335,10 +317,10 @@ export const configUpdateActionsFlash = isAnyOf(
|
||||
setUpnpState,
|
||||
setWanPortToValue,
|
||||
setWanAccess,
|
||||
setupRemoteAccessThunk.fulfilled,
|
||||
// setupRemoteAccessThunk.fulfilled,
|
||||
logoutUser.fulfilled,
|
||||
loginUser.fulfilled,
|
||||
removeSsoUser,
|
||||
// removeSsoUser,
|
||||
setLocalApiKey
|
||||
);
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { remoteAccessLogger } from '@app/core/log.js';
|
||||
import {
|
||||
AccessUrlInput,
|
||||
DynamicRemoteAccessType,
|
||||
URL_TYPE,
|
||||
} from '@app/unraid-api/graph/resolvers/connect/connect.model.js';
|
||||
|
||||
interface DynamicRemoteAccessState {
|
||||
runningType: DynamicRemoteAccessType; // Is Dynamic Remote Access actively running - shows type of access currently running
|
||||
error: string | null;
|
||||
lastPing: number | null;
|
||||
allowedUrl: {
|
||||
ipv4: string | null | undefined;
|
||||
ipv6: string | null | undefined;
|
||||
type: URL_TYPE;
|
||||
name: string | null | undefined;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const initialState: DynamicRemoteAccessState = {
|
||||
runningType: DynamicRemoteAccessType.DISABLED,
|
||||
error: null,
|
||||
lastPing: null,
|
||||
allowedUrl: null,
|
||||
};
|
||||
|
||||
const dynamicRemoteAccess = createSlice({
|
||||
name: 'dynamicRemoteAccess',
|
||||
initialState,
|
||||
reducers: {
|
||||
receivedPing(state) {
|
||||
remoteAccessLogger.info('ping');
|
||||
state.lastPing = Date.now();
|
||||
},
|
||||
clearPing(state) {
|
||||
remoteAccessLogger.info('clearing ping');
|
||||
state.lastPing = null;
|
||||
},
|
||||
setRemoteAccessRunningType(state, action: PayloadAction<DynamicRemoteAccessType>) {
|
||||
state.error = null;
|
||||
state.runningType = action.payload;
|
||||
if (action.payload === DynamicRemoteAccessType.DISABLED) {
|
||||
state.lastPing = null;
|
||||
} else {
|
||||
state.lastPing = Date.now();
|
||||
}
|
||||
},
|
||||
setDynamicRemoteAccessError(state, action: PayloadAction<string>) {
|
||||
state.error = action.payload;
|
||||
},
|
||||
setAllowedRemoteAccessUrl(state, action: PayloadAction<AccessUrlInput | null>) {
|
||||
if (action.payload) {
|
||||
state.allowedUrl = {
|
||||
ipv4: action.payload.ipv4?.toString(),
|
||||
ipv6: action.payload.ipv6?.toString(),
|
||||
type: action.payload.type ?? URL_TYPE.WAN,
|
||||
name: action.payload.name,
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { actions, reducer } = dynamicRemoteAccess;
|
||||
|
||||
export const {
|
||||
receivedPing,
|
||||
clearPing,
|
||||
setAllowedRemoteAccessUrl,
|
||||
setRemoteAccessRunningType,
|
||||
setDynamicRemoteAccessError,
|
||||
} = actions;
|
||||
export const dynamicRemoteAccessReducer = reducer;
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice, isAnyOf } from '@reduxjs/toolkit';
|
||||
|
||||
import type { SubscriptionWithLastPing } from '@app/store/types.js';
|
||||
import { remoteAccessLogger } from '@app/core/log.js';
|
||||
import { addRemoteSubscription } from '@app/store/actions/add-remote-subscription.js';
|
||||
import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status.js';
|
||||
import { logoutUser } from '@app/store/modules/config.js';
|
||||
import { MOTHERSHIP_CRITICAL_STATUSES } from '@app/store/types.js';
|
||||
|
||||
interface RemoteGraphQLStore {
|
||||
subscriptions: Array<SubscriptionWithLastPing>;
|
||||
}
|
||||
|
||||
const initialState: RemoteGraphQLStore = {
|
||||
subscriptions: [],
|
||||
};
|
||||
|
||||
const remoteGraphQLStore = createSlice({
|
||||
name: 'remoteGraphQL',
|
||||
initialState,
|
||||
reducers: {
|
||||
clearSubscription(state, action: PayloadAction<string>) {
|
||||
remoteAccessLogger.debug('Clearing subscription with SHA %s', action.payload);
|
||||
const subscription = state.subscriptions.find((sub) => sub.sha256 === action.payload);
|
||||
if (subscription) {
|
||||
subscription.subscription.unsubscribe();
|
||||
state.subscriptions = state.subscriptions.filter(
|
||||
(subscription) => subscription.sha256 !== action.payload
|
||||
);
|
||||
}
|
||||
|
||||
remoteAccessLogger.debug('Current remote subscriptions: %s', state.subscriptions.length);
|
||||
},
|
||||
renewRemoteSubscription(state, { payload: { sha256 } }: PayloadAction<{ sha256: string }>) {
|
||||
const subscription = state.subscriptions.find((sub) => sub.sha256 === sha256);
|
||||
if (subscription) {
|
||||
subscription.lastPing = Date.now();
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers(builder) {
|
||||
builder.addCase(addRemoteSubscription.rejected, (_, action) => {
|
||||
if (action.error) {
|
||||
remoteAccessLogger.warn('Handling Add Remote Sub Error: %s', action.error.message);
|
||||
}
|
||||
});
|
||||
builder.addCase(addRemoteSubscription.fulfilled, (state, action) => {
|
||||
remoteAccessLogger.info('Successfully added new remote subscription');
|
||||
state.subscriptions.push({
|
||||
...action.payload,
|
||||
lastPing: Date.now(),
|
||||
});
|
||||
}),
|
||||
builder.addMatcher(
|
||||
isAnyOf(logoutUser.pending, setGraphqlConnectionStatus),
|
||||
(state, action) => {
|
||||
if (
|
||||
(action.payload?.status &&
|
||||
MOTHERSHIP_CRITICAL_STATUSES.includes(action.payload.status)) ||
|
||||
action.type === logoutUser.pending.type
|
||||
) {
|
||||
remoteAccessLogger.debug(
|
||||
'Clearing all active remote subscriptions, minigraph is no longer connected.'
|
||||
);
|
||||
for (const sub of state.subscriptions) {
|
||||
sub.subscription.unsubscribe();
|
||||
}
|
||||
state.subscriptions = [];
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const { clearSubscription, renewRemoteSubscription } = remoteGraphQLStore.actions;
|
||||
export const remoteGraphQLReducer = remoteGraphQLStore.reducer;
|
||||
@@ -1,15 +1,12 @@
|
||||
import { combineReducers, UnknownAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { resetStore } from '@app/store/actions/reset-store.js';
|
||||
import { cache } from '@app/store/modules/cache.js';
|
||||
import { configReducer } from '@app/store/modules/config.js';
|
||||
import { dynamicRemoteAccessReducer } from '@app/store/modules/dynamic-remote-access.js';
|
||||
import { dynamix } from '@app/store/modules/dynamix.js';
|
||||
import { emhttp } from '@app/store/modules/emhttp.js';
|
||||
import { mothershipReducer } from '@app/store/modules/minigraph.js';
|
||||
import { paths } from '@app/store/modules/paths.js';
|
||||
import { registrationReducer } from '@app/store/modules/registration.js';
|
||||
import { remoteGraphQLReducer } from '@app/store/modules/remote-graphql.js';
|
||||
import { upnp } from '@app/store/modules/upnp.js';
|
||||
|
||||
/**
|
||||
@@ -18,13 +15,10 @@ import { upnp } from '@app/store/modules/upnp.js';
|
||||
*/
|
||||
const appReducer = combineReducers({
|
||||
config: configReducer,
|
||||
dynamicRemoteAccess: dynamicRemoteAccessReducer,
|
||||
minigraph: mothershipReducer,
|
||||
paths: paths.reducer,
|
||||
emhttp: emhttp.reducer,
|
||||
registration: registrationReducer,
|
||||
remoteGraphQL: remoteGraphQLReducer,
|
||||
cache: cache.reducer,
|
||||
upnp: upnp.reducer,
|
||||
dynamix: dynamix.reducer,
|
||||
});
|
||||
|
||||
@@ -28,10 +28,6 @@ export const startStoreSync = async () => {
|
||||
state.paths['myservers-config-states']
|
||||
) {
|
||||
writeFileSync(join(state.paths.states, 'config.log'), JSON.stringify(state.config, null, 2));
|
||||
writeFileSync(
|
||||
join(state.paths.states, 'dynamicRemoteAccess.log'),
|
||||
JSON.stringify(state.dynamicRemoteAccess, null, 2)
|
||||
);
|
||||
writeFileSync(
|
||||
join(state.paths.states, 'graphql.log'),
|
||||
JSON.stringify(state.minigraph, null, 2)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { MinigraphStatus } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js';
|
||||
import { DynamicRemoteAccessType } from '@app/unraid-api/graph/resolvers/connect/connect.model.js';
|
||||
|
||||
// Define Zod schemas
|
||||
const ApiConfigSchema = z.object({
|
||||
@@ -22,7 +21,7 @@ const RemoteConfigSchema = z.object({
|
||||
accesstoken: z.string(),
|
||||
idtoken: z.string(),
|
||||
refreshtoken: z.string(),
|
||||
dynamicRemoteAccessType: z.nativeEnum(DynamicRemoteAccessType),
|
||||
dynamicRemoteAccessType: z.string(),
|
||||
ssoSubIds: z
|
||||
.string()
|
||||
.transform((val) => {
|
||||
|
||||
@@ -8,15 +8,21 @@ import { LoggerModule } from 'nestjs-pino';
|
||||
|
||||
import { apiLogger } from '@app/core/log.js';
|
||||
import { LOG_LEVEL } from '@app/environment.js';
|
||||
import { PubSubModule } from '@app/unraid-api/app/pubsub.module.js';
|
||||
import { AuthModule } from '@app/unraid-api/auth/auth.module.js';
|
||||
import { AuthenticationGuard } from '@app/unraid-api/auth/authentication.guard.js';
|
||||
import { LegacyConfigModule } from '@app/unraid-api/config/legacy-config.module.js';
|
||||
import { CronModule } from '@app/unraid-api/cron/cron.module.js';
|
||||
import { GraphModule } from '@app/unraid-api/graph/graph.module.js';
|
||||
import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js';
|
||||
import { RestModule } from '@app/unraid-api/rest/rest.module.js';
|
||||
import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
GlobalDepsModule,
|
||||
LegacyConfigModule,
|
||||
PubSubModule,
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
logger: apiLogger,
|
||||
|
||||
19
api/src/unraid-api/app/lifecycle.service.ts
Normal file
19
api/src/unraid-api/app/lifecycle.service.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { execa } from 'execa';
|
||||
|
||||
@Injectable()
|
||||
export class LifecycleService {
|
||||
private readonly logger = new Logger(LifecycleService.name);
|
||||
|
||||
restartApi({ delayMs = 300 }: { delayMs?: number } = {}) {
|
||||
return setTimeout(async () => {
|
||||
this.logger.log('Restarting API');
|
||||
try {
|
||||
await execa('unraid-api', ['restart'], { shell: 'bash', stdio: 'ignore' });
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
}
|
||||
}, delayMs);
|
||||
}
|
||||
}
|
||||
55
api/src/unraid-api/app/pubsub.module.ts
Normal file
55
api/src/unraid-api/app/pubsub.module.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Sets up global pubsub dependencies
|
||||
|
||||
/**------------------------------------------------------------------------
|
||||
* PubSub in the Unraid API
|
||||
*
|
||||
* There are 2 Event Buses in the Unraid API:
|
||||
* 1. GraphQL PubSub (for transport events between the client and server)
|
||||
* 2. EventEmitter PubSub (for domain events within nestjs)
|
||||
*
|
||||
* By separating the buses, we can separate backend logic and processing from
|
||||
* the actual data transport.
|
||||
*
|
||||
* e.g. we can process an event, and then transport it via one or more of
|
||||
* email, sms, discord, graphql subscription, etc without mixing all the
|
||||
* effects together.
|
||||
*------------------------------------------------------------------------**/
|
||||
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
|
||||
import { GRAPHQL_PUBSUB_TOKEN } from '@unraid/shared/pubsub/graphql.pubsub.js';
|
||||
|
||||
import { pubsub } from '@app/core/pubsub.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
/**-----------------------
|
||||
* Domain Event Bus
|
||||
*
|
||||
* Used for backend events within the API.
|
||||
* e.g. User Logout, API key modified, etc.
|
||||
*------------------------**/
|
||||
EventEmitterModule.forRoot({
|
||||
// allow event handlers to subscribe to multiple events
|
||||
wildcard: true,
|
||||
// additional details when an unexpectedly high number of listeners are registered
|
||||
verboseMemoryLeak: true,
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
/**-----------------------
|
||||
* GraphQL Event Bus
|
||||
*
|
||||
* Used for transport events between the client and server.
|
||||
* e.g. Notification added,
|
||||
*------------------------**/
|
||||
{
|
||||
provide: GRAPHQL_PUBSUB_TOKEN,
|
||||
useValue: pubsub,
|
||||
},
|
||||
],
|
||||
exports: [GRAPHQL_PUBSUB_TOKEN, EventEmitterModule],
|
||||
})
|
||||
export class PubSubModule {}
|
||||
@@ -2,6 +2,7 @@ import { Logger } from '@nestjs/common';
|
||||
import { readdir, readFile, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { ensureDir, ensureDirSync } from 'fs-extra';
|
||||
import { AuthActionVerb } from 'nest-authz';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
ApiKeyWithSecret,
|
||||
Permission,
|
||||
} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { Resource, Role } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
|
||||
// Mock the store and its modules
|
||||
vi.mock('@app/store/index.js', () => ({
|
||||
|
||||
@@ -3,6 +3,7 @@ import crypto from 'crypto';
|
||||
import { readdir, readFile, unlink, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { watch } from 'chokidar';
|
||||
import { ValidationError } from 'class-validator';
|
||||
import { ensureDirSync } from 'fs-extra';
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
ApiKeyWithSecret,
|
||||
Permission,
|
||||
} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { Resource, Role } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js';
|
||||
import { batchProcess } from '@app/utils.js';
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { newEnforcer } from 'casbin';
|
||||
import { AuthActionVerb, AuthZService } from 'nest-authz';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -8,7 +9,6 @@ import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
|
||||
import { ApiKey, ApiKeyWithSecret } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { Resource, Role } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
import { UserAccount } from '@app/unraid-api/graph/user/user.model.js';
|
||||
import { FastifyRequest } from '@app/unraid-api/types/fastify.js';
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
import { Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthZService } from 'nest-authz';
|
||||
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
|
||||
import { Permission } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { Role } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
import { UserAccount } from '@app/unraid-api/graph/user/user.model.js';
|
||||
import { FastifyRequest } from '@app/unraid-api/types/fastify.js';
|
||||
import { batchProcess, handleAuthError } from '@app/utils.js';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction } from 'nest-authz';
|
||||
|
||||
import { Resource, Role } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
|
||||
export const BASE_POLICY = `
|
||||
# Admin permissions
|
||||
p, ${Role.ADMIN}, *, *
|
||||
|
||||
122
api/src/unraid-api/auth/sso-user.service.ts
Normal file
122
api/src/unraid-api/auth/sso-user.service.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import type { SsoUserService as ISsoUserService } from '@unraid/shared/services/sso.js';
|
||||
import { GraphQLError } from 'graphql/error/GraphQLError.js';
|
||||
|
||||
import type { ApiConfig } from '@app/unraid-api/config/api-config.module.js';
|
||||
|
||||
@Injectable()
|
||||
export class SsoUserService implements ISsoUserService {
|
||||
private readonly logger = new Logger(SsoUserService.name);
|
||||
private ssoSubIdsConfigKey = 'api.ssoSubIds';
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
/**
|
||||
* Get the current list of SSO user IDs
|
||||
* @returns Array of SSO user IDs
|
||||
*/
|
||||
async getSsoUsers(): Promise<string[]> {
|
||||
const ssoSubIds = this.configService.getOrThrow<ApiConfig['ssoSubIds']>(this.ssoSubIdsConfigKey);
|
||||
return ssoSubIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the complete list of SSO user IDs
|
||||
* @param userIds - The list of SSO user IDs to set
|
||||
* @returns true if a restart is required, false otherwise
|
||||
*/
|
||||
async setSsoUsers(userIds: string[]): Promise<boolean> {
|
||||
const currentUsers = await this.getSsoUsers();
|
||||
const currentUserSet = new Set(currentUsers);
|
||||
const newUserSet = new Set(userIds);
|
||||
|
||||
// If there's no change, no need to update
|
||||
if (newUserSet.symmetricDifference(currentUserSet).size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate user IDs
|
||||
const uuidRegex =
|
||||
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||||
const invalidUserIds = userIds.filter((id) => !uuidRegex.test(id));
|
||||
if (invalidUserIds.length > 0) {
|
||||
throw new GraphQLError(`Invalid SSO user ID's: ${invalidUserIds.join(', ')}`);
|
||||
}
|
||||
|
||||
// Update the config
|
||||
this.configService.set(this.ssoSubIdsConfigKey, userIds);
|
||||
|
||||
// Request a restart if there were no SSO users before
|
||||
return currentUserSet.size === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single SSO user ID
|
||||
* @param userId - The SSO user ID to add
|
||||
* @returns true if a restart is required, false otherwise
|
||||
*/
|
||||
async addSsoUser(userId: string): Promise<boolean> {
|
||||
const currentUsers = await this.getSsoUsers();
|
||||
|
||||
// If user already exists, no need to update
|
||||
if (currentUsers.includes(userId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate user ID
|
||||
const uuidRegex =
|
||||
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||||
if (!uuidRegex.test(userId)) {
|
||||
throw new GraphQLError(`Invalid SSO user ID: ${userId}`);
|
||||
}
|
||||
|
||||
// Add the new user
|
||||
const newUsers = [...currentUsers, userId];
|
||||
this.configService.set(this.ssoSubIdsConfigKey, newUsers);
|
||||
|
||||
// Request a restart if there were no SSO users before
|
||||
return currentUsers.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a single SSO user ID
|
||||
* @param userId - The SSO user ID to remove
|
||||
* @returns true if a restart is required, false otherwise
|
||||
*/
|
||||
async removeSsoUser(userId: string): Promise<boolean> {
|
||||
const currentUsers = await this.getSsoUsers();
|
||||
|
||||
// If user doesn't exist, no need to update
|
||||
if (!currentUsers.includes(userId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove the user
|
||||
const newUsers = currentUsers.filter((id) => id !== userId);
|
||||
this.configService.set(this.ssoSubIdsConfigKey, newUsers);
|
||||
|
||||
// Request a restart if this was the last SSO user
|
||||
return currentUsers.length === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all SSO users
|
||||
* @returns true if a restart is required, false otherwise
|
||||
*/
|
||||
async removeAllSsoUsers(): Promise<boolean> {
|
||||
const currentUsers = await this.getSsoUsers();
|
||||
|
||||
// If no users exist, no need to update
|
||||
if (currentUsers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove all users
|
||||
this.configService.set(this.ssoSubIdsConfigKey, []);
|
||||
|
||||
// Request a restart if there were any SSO users
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Role } from '@unraid/shared/graphql.model.js';
|
||||
import { ChoicesFor, Question, QuestionSet, WhenFor } from 'nest-commander';
|
||||
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { Permission } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { Role } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
|
||||
@QuestionSet({ name: 'add-api-key' })
|
||||
export class AddApiKeyQuestionSet {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthActionVerb } from 'nest-authz';
|
||||
import { Command, CommandRunner, InquirerService, Option } from 'nest-commander';
|
||||
|
||||
@@ -7,7 +8,6 @@ import { AddApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/add-api-key.que
|
||||
import { DeleteApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/delete-api-key.questions.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { Permission } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { Resource, Role } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
|
||||
interface KeyOptions {
|
||||
name: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { SsoUserService } from '@app/unraid-api/auth/sso-user.service.js';
|
||||
import { AddApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/add-api-key.questions.js';
|
||||
import { ApiKeyCommand } from '@app/unraid-api/cli/apikey/api-key.command.js';
|
||||
import { DeleteApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/delete-api-key.questions.js';
|
||||
@@ -25,6 +26,8 @@ import { StatusCommand } from '@app/unraid-api/cli/status.command.js';
|
||||
import { StopCommand } from '@app/unraid-api/cli/stop.command.js';
|
||||
import { SwitchEnvCommand } from '@app/unraid-api/cli/switch-env.command.js';
|
||||
import { VersionCommand } from '@app/unraid-api/cli/version.command.js';
|
||||
import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js';
|
||||
import { LegacyConfigModule } from '@app/unraid-api/config/legacy-config.module.js';
|
||||
import { PluginCliModule } from '@app/unraid-api/plugin/plugin.module.js';
|
||||
|
||||
// cli - plugin add/remove
|
||||
@@ -58,10 +61,11 @@ const DEFAULT_PROVIDERS = [
|
||||
LogService,
|
||||
PM2Service,
|
||||
ApiKeyService,
|
||||
SsoUserService,
|
||||
] as const;
|
||||
|
||||
@Module({
|
||||
imports: [PluginCliModule.register(), PluginCommandModule],
|
||||
imports: [LegacyConfigModule, ApiConfigModule, PluginCliModule.register(), PluginCommandModule],
|
||||
providers: [...DEFAULT_COMMANDS, ...DEFAULT_PROVIDERS],
|
||||
})
|
||||
export class CliModule {}
|
||||
|
||||
@@ -6,8 +6,7 @@ import { join } from 'node:path';
|
||||
import type { Options, Result, ResultPromise } from 'execa';
|
||||
import { execa, ExecaError } from 'execa';
|
||||
|
||||
import { PM2_PATH } from '@app/consts.js';
|
||||
import { PM2_HOME } from '@app/environment.js';
|
||||
import { PM2_HOME, PM2_PATH } from '@app/environment.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
|
||||
type CmdContext = Options & {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import { ECOSYSTEM_PATH } from '@app/consts.js';
|
||||
import { ECOSYSTEM_PATH } from '@app/environment.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
|
||||
|
||||
|
||||
@@ -3,14 +3,10 @@ import { Injectable } from '@nestjs/common';
|
||||
import { CommandRunner, InquirerService, Option, SubCommand } from 'nest-commander';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { store } from '@app/store/index.js';
|
||||
import { addSsoUser, loadConfigFile } from '@app/store/modules/config.js';
|
||||
import { writeConfigSync } from '@app/store/sync/config-disk-sync.js';
|
||||
import { SsoUserService } from '@app/unraid-api/auth/sso-user.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
|
||||
import { AddSSOUserQuestionSet } from '@app/unraid-api/cli/sso/add-sso-user.questions.js';
|
||||
import { StartCommand } from '@app/unraid-api/cli/start.command.js';
|
||||
import { StopCommand } from '@app/unraid-api/cli/stop.command.js';
|
||||
|
||||
interface AddSSOUserCommandOptions {
|
||||
disclaimer: string;
|
||||
@@ -27,8 +23,8 @@ export class AddSSOUserCommand extends CommandRunner {
|
||||
constructor(
|
||||
private readonly logger: LogService,
|
||||
private readonly inquirerService: InquirerService,
|
||||
private readonly startCommand: StartCommand,
|
||||
private readonly stopCommand: StopCommand
|
||||
private readonly restartCommand: RestartCommand,
|
||||
private readonly ssoUserService: SsoUserService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -37,19 +33,12 @@ export class AddSSOUserCommand extends CommandRunner {
|
||||
try {
|
||||
options = await this.inquirerService.prompt(AddSSOUserQuestionSet.name, options);
|
||||
if (options.disclaimer === 'y' && options.username) {
|
||||
await this.stopCommand.run([]);
|
||||
await store.dispatch(loadConfigFile());
|
||||
store.dispatch(addSsoUser(options.username));
|
||||
writeConfigSync('flash');
|
||||
this.logger.info(`User added ${options.username}, starting the API`);
|
||||
await this.startCommand.run([], {});
|
||||
await this.ssoUserService.addSsoUser(options.username);
|
||||
this.logger.info(`User added ${options.username}, restarting the API`);
|
||||
await this.restartCommand.run();
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
this.logger.error('Error adding user: ' + e.message);
|
||||
} else {
|
||||
this.logger.error('Error adding user');
|
||||
}
|
||||
this.logger.error('Error adding user:', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { CommandRunner, SubCommand } from 'nest-commander';
|
||||
|
||||
import { store } from '@app/store/index.js';
|
||||
import { loadConfigFile } from '@app/store/modules/config.js';
|
||||
import { SsoUserService } from '@app/unraid-api/auth/sso-user.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -13,12 +12,15 @@ import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
description: 'List all users for SSO',
|
||||
})
|
||||
export class ListSSOUserCommand extends CommandRunner {
|
||||
constructor(private readonly logger: LogService) {
|
||||
constructor(
|
||||
private readonly logger: LogService,
|
||||
private readonly ssoUserService: SsoUserService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(_input: string[]): Promise<void> {
|
||||
await store.dispatch(loadConfigFile());
|
||||
this.logger.info(store.getState().config.remote.ssoSubIds.split(',').filter(Boolean).join('\n'));
|
||||
const users = await this.ssoUserService.getSsoUsers();
|
||||
this.logger.info(users.join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,10 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { CommandRunner, InquirerService, Option, SubCommand } from 'nest-commander';
|
||||
|
||||
import { store } from '@app/store/index.js';
|
||||
import { loadConfigFile, removeSsoUser } from '@app/store/modules/config.js';
|
||||
import { writeConfigSync } from '@app/store/sync/config-disk-sync.js';
|
||||
import { SsoUserService } from '@app/unraid-api/auth/sso-user.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
|
||||
import { RemoveSSOUserQuestionSet } from '@app/unraid-api/cli/sso/remove-sso-user.questions.js';
|
||||
import { StartCommand } from '@app/unraid-api/cli/start.command.js';
|
||||
import { StopCommand } from '@app/unraid-api/cli/stop.command.js';
|
||||
|
||||
interface RemoveSSOUserCommandOptions {
|
||||
username: string;
|
||||
@@ -24,24 +21,22 @@ export class RemoveSSOUserCommand extends CommandRunner {
|
||||
constructor(
|
||||
private readonly logger: LogService,
|
||||
private readonly inquirerService: InquirerService,
|
||||
private readonly stopCommand: StopCommand,
|
||||
private readonly startCommand: StartCommand
|
||||
private readonly restartCommand: RestartCommand,
|
||||
private readonly ssoUserService: SsoUserService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
public async run(_input: string[], options: RemoveSSOUserCommandOptions): Promise<void> {
|
||||
await store.dispatch(loadConfigFile());
|
||||
options = await this.inquirerService.prompt(RemoveSSOUserQuestionSet.name, options);
|
||||
|
||||
await this.stopCommand.run([]);
|
||||
store.dispatch(removeSsoUser(options.username === 'all' ? null : options.username));
|
||||
if (options.username === 'all') {
|
||||
await this.ssoUserService.removeAllSsoUsers();
|
||||
this.logger.info('All users removed from SSO');
|
||||
} else {
|
||||
await this.ssoUserService.removeSsoUser(options.username);
|
||||
this.logger.info('User removed: ' + options.username);
|
||||
}
|
||||
writeConfigSync('flash');
|
||||
await this.startCommand.run([], {});
|
||||
this.logger.info('Restarting the API');
|
||||
await this.restartCommand.run();
|
||||
}
|
||||
|
||||
@Option({
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { JWTPayload } from 'jose';
|
||||
import { createLocalJWKSet, createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose';
|
||||
import { createLocalJWKSet, createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import { CommandRunner, SubCommand } from 'nest-commander';
|
||||
|
||||
import { JWKS_LOCAL_PAYLOAD, JWKS_REMOTE_LINK } from '@app/consts.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { loadConfigFile } from '@app/store/modules/config.js';
|
||||
import { SsoUserService } from '@app/unraid-api/auth/sso-user.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
|
||||
@SubCommand({
|
||||
@@ -16,7 +15,10 @@ import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
export class ValidateTokenCommand extends CommandRunner {
|
||||
JWKSOffline: ReturnType<typeof createLocalJWKSet>;
|
||||
JWKSOnline: ReturnType<typeof createRemoteJWKSet>;
|
||||
constructor(private readonly logger: LogService) {
|
||||
constructor(
|
||||
private readonly logger: LogService,
|
||||
private readonly ssoUserService: SsoUserService
|
||||
) {
|
||||
super();
|
||||
this.JWKSOffline = createLocalJWKSet(JWKS_LOCAL_PAYLOAD);
|
||||
this.JWKSOnline = createRemoteJWKSet(new URL(JWKS_REMOTE_LINK));
|
||||
@@ -78,14 +80,13 @@ export class ValidateTokenCommand extends CommandRunner {
|
||||
if (!username) {
|
||||
return this.createErrorAndExit('No ID found in token');
|
||||
}
|
||||
const configFile = await store.dispatch(loadConfigFile()).unwrap();
|
||||
if (!configFile.remote?.ssoSubIds) {
|
||||
const ssoUsers = await this.ssoUserService.getSsoUsers();
|
||||
if (ssoUsers.length === 0) {
|
||||
this.createErrorAndExit(
|
||||
'No local user token set to compare to - please set any valid SSO IDs you would like to sign in with'
|
||||
);
|
||||
}
|
||||
const possibleUserIds = configFile.remote.ssoSubIds.split(',');
|
||||
if (possibleUserIds.includes(username)) {
|
||||
if (ssoUsers.includes(username)) {
|
||||
this.logger.info(JSON.stringify({ error: null, valid: true, username }));
|
||||
process.exit(0);
|
||||
} else {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
|
||||
import type { LogLevel } from '@app/core/log.js';
|
||||
import { ECOSYSTEM_PATH } from '@app/consts.js';
|
||||
import { levels } from '@app/core/log.js';
|
||||
import { ECOSYSTEM_PATH } from '@app/environment.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
|
||||
import { ECOSYSTEM_PATH } from '@app/consts.js';
|
||||
import { ECOSYSTEM_PATH } from '@app/environment.js';
|
||||
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
|
||||
|
||||
interface StopCommandOptions {
|
||||
|
||||
104
api/src/unraid-api/config/api-config.module.ts
Normal file
104
api/src/unraid-api/config/api-config.module.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Injectable, Logger, Module } from '@nestjs/common';
|
||||
import { ConfigService, registerAs } from '@nestjs/config';
|
||||
|
||||
import type { ApiConfig } from '@unraid/shared/services/api-config.js';
|
||||
import { csvStringToArray } from '@unraid/shared/util/data.js';
|
||||
import { fileExists } from '@unraid/shared/util/file.js';
|
||||
import { debounceTime } from 'rxjs/operators';
|
||||
|
||||
import { API_VERSION } from '@app/environment.js';
|
||||
import { ApiStateConfig } from '@app/unraid-api/config/factory/api-state.model.js';
|
||||
import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.helper.js';
|
||||
|
||||
export { type ApiConfig };
|
||||
|
||||
const createDefaultConfig = (): ApiConfig => ({
|
||||
version: API_VERSION,
|
||||
extraOrigins: [],
|
||||
sandbox: false,
|
||||
ssoSubIds: [],
|
||||
});
|
||||
|
||||
/**
|
||||
* Loads the API config from disk. If not found, returns the default config, but does not persist it.
|
||||
*/
|
||||
export const apiConfig = registerAs<ApiConfig>('api', async () => {
|
||||
const defaultConfig = createDefaultConfig();
|
||||
const apiConfig = new ApiStateConfig<ApiConfig>(
|
||||
{
|
||||
name: 'api',
|
||||
defaultConfig,
|
||||
parse: (data) => data as ApiConfig,
|
||||
},
|
||||
new ConfigPersistenceHelper()
|
||||
);
|
||||
const diskConfig = await apiConfig.parseConfig();
|
||||
return {
|
||||
...defaultConfig,
|
||||
...diskConfig,
|
||||
version: API_VERSION,
|
||||
};
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
class ApiConfigPersistence {
|
||||
private configModel: ApiStateConfig<ApiConfig>;
|
||||
private logger = new Logger(ApiConfigPersistence.name);
|
||||
get filePath() {
|
||||
return this.configModel.filePath;
|
||||
}
|
||||
get config() {
|
||||
return this.configService.getOrThrow('api');
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly persistenceHelper: ConfigPersistenceHelper
|
||||
) {
|
||||
this.configModel = new ApiStateConfig<ApiConfig>(
|
||||
{
|
||||
name: 'api',
|
||||
defaultConfig: createDefaultConfig(),
|
||||
parse: (data) => data as ApiConfig,
|
||||
},
|
||||
this.persistenceHelper
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
if (!(await fileExists(this.filePath))) {
|
||||
this.migrateFromMyServersConfig();
|
||||
}
|
||||
await this.persistenceHelper.persistIfChanged(this.filePath, this.config);
|
||||
this.configService.changes$.pipe(debounceTime(500)).subscribe({
|
||||
next: async ({ newValue, oldValue, path }) => {
|
||||
if (path.startsWith('api')) {
|
||||
this.logger.verbose(`Config changed: ${path} from ${oldValue} to ${newValue}`);
|
||||
await this.persistenceHelper.persistIfChanged(this.filePath, this.config);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.logger.error('Error receiving config changes:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private migrateFromMyServersConfig() {
|
||||
const { local, api, remote } = this.configService.get('store.config', {});
|
||||
const sandbox = local?.sandbox;
|
||||
const extraOrigins = csvStringToArray(api?.extraOrigins ?? '').filter(
|
||||
(origin) => origin.startsWith('http://') || origin.startsWith('https://')
|
||||
);
|
||||
const ssoSubIds = csvStringToArray(remote?.ssoSubIds ?? '');
|
||||
|
||||
this.configService.set('api.sandbox', sandbox === 'yes');
|
||||
this.configService.set('api.extraOrigins', extraOrigins);
|
||||
this.configService.set('api.ssoSubIds', ssoSubIds);
|
||||
}
|
||||
}
|
||||
|
||||
// apiConfig should be registered in root config in app.module.ts, not here.
|
||||
@Module({
|
||||
providers: [ApiConfigPersistence, ConfigPersistenceHelper],
|
||||
})
|
||||
export class ApiConfigModule {}
|
||||
33
api/src/unraid-api/config/config.loader.ts
Normal file
33
api/src/unraid-api/config/config.loader.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { isDefined } from 'class-validator';
|
||||
|
||||
import * as Env from '@app/environment.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
|
||||
/**
|
||||
* Provides environment-related app configuration for the NestJS Config.
|
||||
*
|
||||
* These values are not namespaced. They are expected to be constant for the lifetime of the app,
|
||||
* so no sync logic is required.
|
||||
*
|
||||
* @returns
|
||||
*/
|
||||
export const loadAppEnvironment = () => {
|
||||
const configEntries = Object.entries(Env).filter(
|
||||
([, value]) => typeof value !== 'function' && isDefined(value)
|
||||
);
|
||||
return Object.fromEntries(configEntries);
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides the legacy redux store's state under the `store` key.
|
||||
*
|
||||
* This is used to (initially) provide the store to the NestJS Config.
|
||||
* It will not keep them in sync.
|
||||
*
|
||||
* @returns
|
||||
*/
|
||||
export const loadLegacyStore = () => {
|
||||
return {
|
||||
store: store.getState(),
|
||||
};
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import { join } from 'path';
|
||||
|
||||
import { fileExists } from '@app/core/utils/files/file-exists.js';
|
||||
import { PATHS_CONFIG_MODULES } from '@app/environment.js';
|
||||
import { makeConfigToken } from '@app/unraid-api/config/config.injection.js';
|
||||
import { makeConfigToken } from '@app/unraid-api/config/factory/config.injection.js';
|
||||
import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.helper.js';
|
||||
|
||||
export interface ApiStateConfigOptions<T> {
|
||||
@@ -21,7 +21,7 @@ export interface ApiStateConfigOptions<T> {
|
||||
}
|
||||
|
||||
export class ApiStateConfig<T> {
|
||||
private config: T;
|
||||
#config: T;
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@@ -29,7 +29,7 @@ export class ApiStateConfig<T> {
|
||||
readonly persistenceHelper: ConfigPersistenceHelper
|
||||
) {
|
||||
// avoid sharing a reference with the given default config. This allows us to re-use it.
|
||||
this.config = structuredClone(options.defaultConfig);
|
||||
this.#config = structuredClone(options.defaultConfig);
|
||||
this.logger = new Logger(this.token);
|
||||
}
|
||||
|
||||
@@ -46,12 +46,16 @@ export class ApiStateConfig<T> {
|
||||
return join(PATHS_CONFIG_MODULES, this.fileName);
|
||||
}
|
||||
|
||||
get config() {
|
||||
return this.#config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the config to the file system. Will never throw.
|
||||
* @param config - The config to persist.
|
||||
* @returns True if the config was written successfully, false otherwise.
|
||||
*/
|
||||
async persist(config = this.config) {
|
||||
async persist(config = this.#config) {
|
||||
try {
|
||||
await this.persistenceHelper.persistIfChanged(this.filePath, config);
|
||||
return true;
|
||||
@@ -86,10 +90,10 @@ export class ApiStateConfig<T> {
|
||||
try {
|
||||
const config = await this.parseConfig();
|
||||
if (config) {
|
||||
this.config = config;
|
||||
this.#config = config;
|
||||
} else {
|
||||
this.logger.log(`Config file does not exist. Writing default config.`);
|
||||
this.config = this.options.defaultConfig;
|
||||
this.#config = this.options.defaultConfig;
|
||||
await this.persist();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -98,8 +102,8 @@ export class ApiStateConfig<T> {
|
||||
}
|
||||
|
||||
update(config: Partial<T>) {
|
||||
const proposedConfig = this.options.parse({ ...this.config, ...config });
|
||||
this.config = proposedConfig;
|
||||
const proposedConfig = this.options.parse({ ...this.#config, ...config });
|
||||
this.#config = proposedConfig;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { DynamicModule, Provider } from '@nestjs/common';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
|
||||
import type { ApiStateConfigOptions } from '@app/unraid-api/config/api-state.model.js';
|
||||
import type { ApiStateConfigPersistenceOptions } from '@app/unraid-api/config/api-state.service.js';
|
||||
import { ApiStateConfig } from '@app/unraid-api/config/api-state.model.js';
|
||||
import { ScheduledConfigPersistence } from '@app/unraid-api/config/api-state.service.js';
|
||||
import { makeConfigToken } from '@app/unraid-api/config/config.injection.js';
|
||||
import type { ApiStateConfigOptions } from '@app/unraid-api/config/factory/api-state.model.js';
|
||||
import type { ApiStateConfigPersistenceOptions } from '@app/unraid-api/config/factory/api-state.service.js';
|
||||
import { ApiStateConfig } from '@app/unraid-api/config/factory/api-state.model.js';
|
||||
import { ScheduledConfigPersistence } from '@app/unraid-api/config/factory/api-state.service.js';
|
||||
import { makeConfigToken } from '@app/unraid-api/config/factory/config.injection.js';
|
||||
import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.helper.js';
|
||||
|
||||
type ApiStateRegisterOptions<ConfigType> = ApiStateConfigOptions<ConfigType> & {
|
||||
@@ -2,8 +2,8 @@ import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
|
||||
import type { ApiStateConfig } from '@app/unraid-api/config/api-state.model.js';
|
||||
import { makeConfigToken } from '@app/unraid-api/config/config.injection.js';
|
||||
import type { ApiStateConfig } from '@app/unraid-api/config/factory/api-state.model.js';
|
||||
import { makeConfigToken } from '@app/unraid-api/config/factory/config.injection.js';
|
||||
|
||||
export interface ApiStateConfigPersistenceOptions {
|
||||
/** How often to persist the config to the file system, in milliseconds. Defaults to 10 seconds. */
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
|
||||
import type { ConfigFeatures } from '@app/unraid-api/config/config.interface.js';
|
||||
import type { ConfigFeatures } from '@app/unraid-api/config/factory/config.interface.js';
|
||||
|
||||
/**
|
||||
* Creates a string token representation of the arguements. Pure function.
|
||||
20
api/src/unraid-api/config/legacy-config.module.ts
Normal file
20
api/src/unraid-api/config/legacy-config.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// This modules syncs the legacy config with the nest config
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { apiConfig } from '@app/unraid-api/config/api-config.module.js';
|
||||
import { loadAppEnvironment, loadLegacyStore } from '@app/unraid-api/config/config.loader.js';
|
||||
import { StoreSyncService } from '@app/unraid-api/config/store-sync.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [loadAppEnvironment, loadLegacyStore, apiConfig],
|
||||
}),
|
||||
],
|
||||
providers: [StoreSyncService],
|
||||
exports: [StoreSyncService],
|
||||
})
|
||||
export class LegacyConfigModule {}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
|
||||
import { fileExists } from '@unraid/shared/util/file.js';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
@Injectable()
|
||||
@@ -19,6 +20,10 @@ export class ConfigPersistenceHelper {
|
||||
* @throws {Error} if the config file is not writable.
|
||||
*/
|
||||
async persistIfChanged(filePath: string, data: unknown): Promise<boolean> {
|
||||
if (!(await fileExists(filePath))) {
|
||||
await writeFile(filePath, JSON.stringify(data ?? {}, null, 2));
|
||||
return true;
|
||||
}
|
||||
const currentData = JSON.parse(await readFile(filePath, 'utf8'));
|
||||
const stagedData = JSON.parse(JSON.stringify(data));
|
||||
if (isEqual(currentData, stagedData)) {
|
||||
|
||||
23
api/src/unraid-api/config/store-sync.service.ts
Normal file
23
api/src/unraid-api/config/store-sync.service.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import type { Unsubscribe } from '@reduxjs/toolkit';
|
||||
|
||||
import { store } from '@app/store/index.js';
|
||||
|
||||
@Injectable()
|
||||
export class StoreSyncService implements OnModuleDestroy {
|
||||
private unsubscribe: Unsubscribe;
|
||||
private logger = new Logger(StoreSyncService.name);
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.unsubscribe = store.subscribe(() => {
|
||||
this.configService.set('store', store.getState());
|
||||
this.logger.verbose('Synced store to NestJS Config');
|
||||
});
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
this.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,32 @@
|
||||
import type { ApolloDriverConfig } from '@nestjs/apollo';
|
||||
import { ApolloDriver } from '@nestjs/apollo';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { GraphQLModule } from '@nestjs/graphql';
|
||||
|
||||
import { NoUnusedVariablesRule } from 'graphql';
|
||||
import { GraphQLBigInt, JSONResolver, URLResolver } from 'graphql-scalars';
|
||||
|
||||
import { ENVIRONMENT } from '@app/environment.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
import {
|
||||
UsePermissionsDirective,
|
||||
usePermissionsSchemaTransformer,
|
||||
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { NoUnusedVariablesRule } from 'graphql';
|
||||
|
||||
import { ENVIRONMENT } from '@app/environment.js';
|
||||
import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js';
|
||||
import { ResolversModule } from '@app/unraid-api/graph/resolvers/resolvers.module.js';
|
||||
import { sandboxPlugin } from '@app/unraid-api/graph/sandbox-plugin.js';
|
||||
import { PrefixedID as PrefixedIDScalar } from '@app/unraid-api/graph/scalars/graphql-type-prefixed-id.js';
|
||||
import { createSandboxPlugin } from '@app/unraid-api/graph/sandbox-plugin.js';
|
||||
import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js';
|
||||
import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
GlobalDepsModule,
|
||||
ResolversModule,
|
||||
GraphQLModule.forRootAsync<ApolloDriverConfig>({
|
||||
driver: ApolloDriver,
|
||||
imports: [PluginModule.register()],
|
||||
inject: [],
|
||||
useFactory: async () => {
|
||||
imports: [PluginModule.register(), ApiConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => {
|
||||
const isSandboxEnabled = () => Boolean(configService.get('api.sandbox'));
|
||||
return {
|
||||
autoSchemaFile:
|
||||
ENVIRONMENT === 'development'
|
||||
@@ -32,8 +34,8 @@ import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
|
||||
path: './generated-schema.graphql',
|
||||
}
|
||||
: true,
|
||||
introspection: getters.config()?.local?.sandbox === 'yes',
|
||||
playground: false,
|
||||
introspection: isSandboxEnabled(),
|
||||
playground: false, // we handle this in the sandbox plugin
|
||||
context: async ({ req, connectionParams, extra }) => {
|
||||
return {
|
||||
req,
|
||||
@@ -41,7 +43,7 @@ import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
|
||||
extra,
|
||||
};
|
||||
},
|
||||
plugins: [sandboxPlugin] as any[],
|
||||
plugins: [createSandboxPlugin(isSandboxEnabled)] as any[],
|
||||
subscriptions: {
|
||||
'graphql-ws': {
|
||||
path: '/graphql',
|
||||
@@ -57,7 +59,7 @@ import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
|
||||
},
|
||||
}),
|
||||
],
|
||||
providers: [PrefixedIDScalar],
|
||||
providers: [],
|
||||
exports: [GraphQLModule],
|
||||
})
|
||||
export class GraphModule {}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Field, InputType, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { Node, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
@@ -12,9 +14,6 @@ import {
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { Node, Resource, Role } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
import { PrefixedID } from '@app/unraid-api/graph/scalars/graphql-type-prefixed-id.js';
|
||||
|
||||
@ObjectType()
|
||||
export class Permission {
|
||||
@Field(() => Resource)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Role } from '@unraid/shared/graphql.model.js';
|
||||
import { newEnforcer } from 'casbin';
|
||||
import { AuthZService } from 'nest-authz';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
DeleteApiKeyInput,
|
||||
} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { ApiKeyMutationsResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.mutation.js';
|
||||
import { Role } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
|
||||
describe('ApiKeyMutationsResolver', () => {
|
||||
let resolver: ApiKeyMutationsResolver;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import {
|
||||
AddRoleForApiKeyInput,
|
||||
ApiKeyWithSecret,
|
||||
@@ -14,7 +16,6 @@ import {
|
||||
DeleteApiKeyInput,
|
||||
RemoveRoleFromApiKeyInput,
|
||||
} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { Resource, Role } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
import { ApiKeyMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js';
|
||||
import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js';
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Role } from '@unraid/shared/graphql.model.js';
|
||||
import { newEnforcer } from 'casbin';
|
||||
import { AuthZService } from 'nest-authz';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -7,7 +8,6 @@ import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
|
||||
import { ApiKey, ApiKeyWithSecret } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { ApiKeyResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.resolver.js';
|
||||
import { Role } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
|
||||
describe('ApiKeyResolver', () => {
|
||||
let resolver: ApiKeyResolver;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Args, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { ApiKey, Permission } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { Resource, Role } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
import { PrefixedID } from '@app/unraid-api/graph/scalars/graphql-type-prefixed-id.js';
|
||||
|
||||
@Resolver(() => ApiKey)
|
||||
export class ApiKeyResolver {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Field, InputType, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { Node } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { GraphQLBigInt } from 'graphql-scalars';
|
||||
|
||||
import { Node } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
import { PrefixedID } from '@app/unraid-api/graph/scalars/graphql-type-prefixed-id.js';
|
||||
|
||||
@ObjectType()
|
||||
export class Capacity {
|
||||
@Field(() => String, { description: 'Free capacity' })
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Args, Mutation, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import {
|
||||
ArrayDisk,
|
||||
ArrayDiskInput,
|
||||
@@ -13,9 +16,7 @@ import {
|
||||
UnraidArray,
|
||||
} from '@app/unraid-api/graph/resolvers/array/array.model.js';
|
||||
import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js';
|
||||
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
import { ArrayMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js';
|
||||
import { PrefixedID } from '@app/unraid-api/graph/scalars/graphql-type-prefixed-id.js';
|
||||
|
||||
/**
|
||||
* Nested Resolvers for Mutations MUST use @ResolveField() instead of @Mutation()
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { UnraidArray } from '@app/unraid-api/graph/resolvers/array/array.model.js';
|
||||
import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js';
|
||||
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
|
||||
@Resolver('Array')
|
||||
export class ArrayResolver {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Args, Mutation, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { GraphQLJSON } from 'graphql-scalars';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { GraphQLJSON } from 'graphql-scalars';
|
||||
|
||||
import { ParityService } from '@app/unraid-api/graph/resolvers/array/parity.service.js';
|
||||
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
import { ParityCheckMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { PubSub } from 'graphql-subscriptions';
|
||||
|
||||
import { PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { PubSub } from 'graphql-subscriptions';
|
||||
|
||||
import { PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js';
|
||||
import { ParityCheck } from '@app/unraid-api/graph/resolvers/array/parity.model.js';
|
||||
import { ParityService } from '@app/unraid-api/graph/resolvers/array/parity.service.js';
|
||||
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
|
||||
const pubSub = new PubSub();
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Field, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
export enum MinigraphStatus {
|
||||
PRE_INIT = 'PRE_INIT',
|
||||
CONNECTING = 'CONNECTING',
|
||||
@@ -7,73 +5,3 @@ export enum MinigraphStatus {
|
||||
PING_FAILURE = 'PING_FAILURE',
|
||||
ERROR_RETRYING = 'ERROR_RETRYING',
|
||||
}
|
||||
|
||||
registerEnumType(MinigraphStatus, {
|
||||
name: 'MinigraphStatus',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class ApiKeyResponse {
|
||||
@Field(() => Boolean)
|
||||
valid!: boolean;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class MinigraphqlResponse {
|
||||
@Field(() => MinigraphStatus)
|
||||
status!: MinigraphStatus;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
timeout?: number | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class CloudResponse {
|
||||
@Field(() => String)
|
||||
status!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
ip?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class RelayResponse {
|
||||
@Field(() => String)
|
||||
status!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
timeout?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class Cloud {
|
||||
@Field(() => String, { nullable: true })
|
||||
error?: string;
|
||||
|
||||
@Field(() => ApiKeyResponse)
|
||||
apiKey!: ApiKeyResponse;
|
||||
|
||||
@Field(() => RelayResponse, { nullable: true })
|
||||
relay?: RelayResponse;
|
||||
|
||||
@Field(() => MinigraphqlResponse)
|
||||
minigraphql!: MinigraphqlResponse;
|
||||
|
||||
@Field(() => CloudResponse)
|
||||
cloud!: CloudResponse;
|
||||
|
||||
@Field(() => [String])
|
||||
allowedOrigins!: string[];
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { CloudResolver } from '@app/unraid-api/graph/resolvers/cloud/cloud.resolver.js';
|
||||
|
||||
describe('CloudResolver', () => {
|
||||
let resolver: CloudResolver;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [CloudResolver],
|
||||
}).compile();
|
||||
|
||||
resolver = module.get<CloudResolver>(CloudResolver);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(resolver).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { getAllowedOrigins } from '@app/common/allowed-origins.js';
|
||||
import { checkApi } from '@app/graphql/resolvers/query/cloud/check-api.js';
|
||||
import { checkCloud } from '@app/graphql/resolvers/query/cloud/check-cloud.js';
|
||||
import { checkMinigraphql } from '@app/graphql/resolvers/query/cloud/check-minigraphql.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
|
||||
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
import { Cloud } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js';
|
||||
|
||||
@Resolver(() => Cloud)
|
||||
export class CloudResolver {
|
||||
@Query(() => Cloud)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.CLOUD,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async cloud(): Promise<Cloud> {
|
||||
const minigraphql = checkMinigraphql();
|
||||
const [apiKey, cloud] = await Promise.all([checkApi(), checkCloud()]);
|
||||
|
||||
return {
|
||||
relay: {
|
||||
// Left in for UPC backwards compat.
|
||||
error: undefined,
|
||||
status: 'connected',
|
||||
timeout: undefined,
|
||||
},
|
||||
apiKey,
|
||||
minigraphql,
|
||||
cloud,
|
||||
allowedOrigins: getAllowedOrigins(),
|
||||
error:
|
||||
`${apiKey.error ? `API KEY: ${apiKey.error}` : ''}${
|
||||
cloud.error ? `NETWORK: ${cloud.error}` : ''
|
||||
}${minigraphql.error ? `CLOUD: ${minigraphql.error}` : ''}` || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { Node } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
import { Node } from '@unraid/shared/graphql.model.js';
|
||||
|
||||
@ObjectType({
|
||||
implements: () => Node,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user