From 5611e38babd8d6b339a9e30f36dfd0b213f57cbe Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 3 Sep 2025 09:16:25 -0400 Subject: [PATCH] fix: easier programmatic key management --- api/dev/configs/api.json | 2 +- .../public/programmatic-api-key-management.md | 252 ++++++++++++++++++ .../cli/apikey/api-key.command.spec.ts | 149 +++++++++++ .../unraid-api/cli/apikey/api-key.command.ts | 104 ++++++-- .../dynamix.my.servers/ConfigDownload.page | 6 - web/components/Logs/SingleLogViewer.vue | 39 +-- 6 files changed, 507 insertions(+), 45 deletions(-) create mode 100644 api/docs/public/programmatic-api-key-management.md delete mode 100644 plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/ConfigDownload.page diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index 6b4bf24fd..3aef850b3 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -1,5 +1,5 @@ { - "version": "4.17.0", + "version": "4.18.0", "extraOrigins": [], "sandbox": true, "ssoSubIds": [], diff --git a/api/docs/public/programmatic-api-key-management.md b/api/docs/public/programmatic-api-key-management.md new file mode 100644 index 000000000..d306e9759 --- /dev/null +++ b/api/docs/public/programmatic-api-key-management.md @@ -0,0 +1,252 @@ +--- +title: Programmatic API Key Management +description: Create, use, and delete API keys programmatically for automated workflows +sidebar_position: 4 +--- + +# Programmatic API Key Management + +This guide explains how to create, use, and delete API keys programmatically using the Unraid API CLI, enabling automated workflows and scripts. + +## Overview + +The `unraid-api apikey` command supports both interactive and non-interactive modes, making it suitable for: + +- Automated deployment scripts +- CI/CD pipelines +- Temporary access provisioning +- Infrastructure as code workflows + +:::tip[Quick Start] +Jump to the [Complete Workflow Example](#complete-workflow-example) to see everything in action. +::: + +## Creating API Keys Programmatically + +### Basic Creation with JSON Output + +Use the `--json` flag to get machine-readable output: + +```bash +unraid-api apikey --create --name "workflow key" --roles ADMIN --json +``` + +**Output:** + +```json +{ + "key": "your-generated-api-key-here", + "name": "workflow key", + "id": "generated-uuid" +} +``` + +### Advanced Creation with Permissions + +```bash +unraid-api apikey --create \ + --name "limited access key" \ + --permissions "DOCKER:READ_ANY,ARRAY:READ_ANY" \ + --description "Read-only access for monitoring" \ + --json +``` + +### Handling Existing Keys + +If a key with the same name exists, use `--overwrite`: + +```bash +unraid-api apikey --create --name "existing key" --roles ADMIN --overwrite --json +``` + +:::warning[Key Replacement] +The `--overwrite` flag will permanently replace the existing key. The old key will be immediately invalidated. +::: + +## Deleting API Keys Programmatically + +### Non-Interactive Deletion + +Delete a key by name without prompts: + +```bash +unraid-api apikey --delete --name "workflow key" +``` + +**Output:** + +``` +Successfully deleted 1 API key +``` + +### JSON Output for Deletion + +Use `--json` flag for machine-readable delete confirmation: + +```bash +unraid-api apikey --delete --name "workflow key" --json +``` + +**Success Output:** + +```json +{ + "deleted": 1, + "keys": [ + { + "id": "generated-uuid", + "name": "workflow key" + } + ] +} +``` + +**Error Output:** + +```json +{ + "deleted": 0, + "error": "No API key found with name: nonexistent key" +} +``` + +### Error Handling + +When the specified key doesn't exist: + +```bash +unraid-api apikey --delete --name "nonexistent key" +# Output: No API keys found to delete +``` + +**JSON Error Output:** + +```json +{ + "deleted": 0, + "message": "No API keys found to delete" +} +``` + +## Complete Workflow Example + +Here's a complete example for temporary access provisioning: + +```bash +#!/bin/bash +set -e + +# 1. Create temporary API key +echo "Creating temporary API key..." +KEY_DATA=$(unraid-api apikey --create \ + --name "temp deployment key" \ + --roles ADMIN \ + --description "Temporary key for deployment $(date)" \ + --json) + +# 2. Extract the API key +API_KEY=$(echo "$KEY_DATA" | jq -r '.key') +echo "API key created successfully" + +# 3. Use the key for operations +echo "Configuring services..." +curl -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"provider": "azure", "clientId": "your-client-id"}' \ + http://localhost:3001/graphql + +# 4. Clean up (always runs, even on error) +trap 'echo "Cleaning up..."; unraid-api apikey --delete --name "temp deployment key"' EXIT + +echo "Deployment completed successfully" +``` + +## Command Reference + +### Create Command Options + +| Flag | Description | Example | +| ----------------------- | ----------------------- | --------------------------------- | +| `--name ` | Key name (required) | `--name "my key"` | +| `--roles ` | Comma-separated roles | `--roles ADMIN,VIEWER` | +| `--permissions ` | Resource:action pairs | `--permissions "DOCKER:READ_ANY"` | +| `--description ` | Key description | `--description "CI/CD key"` | +| `--overwrite` | Replace existing key | `--overwrite` | +| `--json` | Machine-readable output | `--json` | + +### Available Roles + +- `ADMIN` - Full system access +- `CONNECT` - Unraid Connect features +- `VIEWER` - Read-only access +- `GUEST` - Limited access + +### Available Resources and Actions + +**Resources:** `ACTIVATION_CODE`, `API_KEY`, `ARRAY`, `CLOUD`, `CONFIG`, `CONNECT`, `CONNECT__REMOTE_ACCESS`, `CUSTOMIZATIONS`, `DASHBOARD`, `DISK`, `DISPLAY`, `DOCKER`, `FLASH`, `INFO`, `LOGS`, `ME`, `NETWORK`, `NOTIFICATIONS`, `ONLINE`, `OS`, `OWNER`, `PERMISSION`, `REGISTRATION`, `SERVERS`, `SERVICES`, `SHARE`, `VARS`, `VMS`, `WELCOME` + +**Actions:** `CREATE_ANY`, `CREATE_OWN`, `READ_ANY`, `READ_OWN`, `UPDATE_ANY`, `UPDATE_OWN`, `DELETE_ANY`, `DELETE_OWN` + +### Delete Command Options + +| Flag | Description | Example | +| --------------- | ------------------------ | ----------------- | +| `--delete` | Enable delete mode | `--delete` | +| `--name ` | Key to delete (optional) | `--name "my key"` | + +**Note:** If `--name` is omitted, the command runs interactively. + +## Best Practices + +:::info[Security Best Practices] +**Minimal Permissions** + +- Use specific permissions instead of ADMIN role when possible +- Example: `--permissions "DOCKER:READ_ANY"` instead of `--roles ADMIN` + +**Key Lifecycle Management** + +- Always clean up temporary keys after use +- Store API keys securely (environment variables, secrets management) +- Use descriptive names and descriptions for audit trails + ::: + +### Error Handling + +- Check exit codes (`$?`) after each command +- Use `set -e` in bash scripts to fail fast +- Implement proper cleanup with `trap` + +### Key Naming + +- Use descriptive names that include purpose and date +- Names must contain only letters, numbers, and spaces +- Unicode letters are supported + +## Troubleshooting + +### Common Issues + +:::note[Common Error Messages] + +**"API key name must contain only letters, numbers, and spaces"** + +- **Solution:** Remove special characters like hyphens, underscores, or symbols + +**"API key with name 'x' already exists"** + +- **Solution:** Use `--overwrite` flag or choose a different name + +**"Please add at least one role or permission to the key"** + +- **Solution:** Specify either `--roles` or `--permissions` (or both) + +::: + +### Debug Mode + +For troubleshooting, run with debug logging: + +```bash +LOG_LEVEL=debug unraid-api apikey --create --name "debug key" --roles ADMIN +``` diff --git a/api/src/unraid-api/cli/apikey/api-key.command.spec.ts b/api/src/unraid-api/cli/apikey/api-key.command.spec.ts index c24d98900..32129ae8c 100644 --- a/api/src/unraid-api/cli/apikey/api-key.command.spec.ts +++ b/api/src/unraid-api/cli/apikey/api-key.command.spec.ts @@ -282,4 +282,153 @@ describe('ApiKeyCommand', () => { expect(result).toEqual([Role.ADMIN, Role.CONNECT]); }); }); + + describe('JSON output functionality', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('should output JSON when creating key with --json flag', async () => { + const mockKey = { + id: 'test-id-123', + key: 'test-key-456', + name: 'JSON_TEST', + roles: [Role.ADMIN], + createdAt: new Date().toISOString(), + permissions: [], + }; + vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null); + vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey); + + await command.run([], { + name: 'JSON_TEST', + create: true, + roles: [Role.ADMIN], + json: true, + }); + + expect(consoleSpy).toHaveBeenCalledWith( + JSON.stringify({ key: 'test-key-456', name: 'JSON_TEST', id: 'test-id-123' }) + ); + expect(logService.log).not.toHaveBeenCalledWith('test-key-456'); + }); + + it('should output JSON when fetching existing key with --json flag', async () => { + const existingKey = { + id: 'existing-id-456', + key: 'existing-key-789', + name: 'EXISTING_JSON', + roles: [Role.VIEWER], + createdAt: new Date().toISOString(), + permissions: [], + }; + vi.spyOn(apiKeyService, 'findByField').mockReturnValue(existingKey); + + await command.run([], { + name: 'EXISTING_JSON', + create: false, + json: true, + }); + + expect(consoleSpy).toHaveBeenCalledWith( + JSON.stringify({ key: 'existing-key-789', name: 'EXISTING_JSON', id: 'existing-id-456' }) + ); + expect(logService.log).not.toHaveBeenCalledWith('existing-key-789'); + }); + + it('should output JSON when deleting key with --json flag', async () => { + const existingKeys = [ + { + id: 'delete-id-123', + name: 'DELETE_JSON', + key: 'delete-key-456', + roles: [Role.GUEST], + createdAt: new Date().toISOString(), + permissions: [], + }, + ]; + vi.spyOn(apiKeyService, 'findAll').mockResolvedValue(existingKeys); + vi.spyOn(apiKeyService, 'deleteApiKeys').mockResolvedValue(); + + await command.run([], { + name: 'DELETE_JSON', + delete: true, + json: true, + }); + + expect(consoleSpy).toHaveBeenCalledWith( + JSON.stringify({ + deleted: 1, + keys: [{ id: 'delete-id-123', name: 'DELETE_JSON' }], + }) + ); + expect(logService.log).not.toHaveBeenCalledWith('Successfully deleted 1 API key'); + }); + + it('should output JSON error when deleting non-existent key with --json flag', async () => { + vi.spyOn(apiKeyService, 'findAll').mockResolvedValue([]); + + await command.run([], { + name: 'NONEXISTENT', + delete: true, + json: true, + }); + + expect(consoleSpy).toHaveBeenCalledWith( + JSON.stringify({ deleted: 0, message: 'No API keys found to delete' }) + ); + expect(logService.log).not.toHaveBeenCalledWith('No API keys found to delete'); + }); + + it('should not suppress creation message when not using JSON', async () => { + const mockKey = { + id: 'test-id', + key: 'test-key', + name: 'NO_JSON_TEST', + roles: [Role.ADMIN], + createdAt: new Date().toISOString(), + permissions: [], + }; + vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null); + vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey); + + await command.run([], { + name: 'NO_JSON_TEST', + create: true, + roles: [Role.ADMIN], + json: false, + }); + + expect(logService.log).toHaveBeenCalledWith('Creating API Key...'); + expect(logService.log).toHaveBeenCalledWith('test-key'); + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it('should suppress creation message when using JSON', async () => { + const mockKey = { + id: 'test-id', + key: 'test-key', + name: 'JSON_SUPPRESS_TEST', + roles: [Role.ADMIN], + createdAt: new Date().toISOString(), + permissions: [], + }; + vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null); + vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey); + + await command.run([], { + name: 'JSON_SUPPRESS_TEST', + create: true, + roles: [Role.ADMIN], + json: true, + }); + + expect(logService.log).not.toHaveBeenCalledWith('Creating API Key...'); + expect(consoleSpy).toHaveBeenCalledWith( + JSON.stringify({ key: 'test-key', name: 'JSON_SUPPRESS_TEST', id: 'test-id' }) + ); + }); + }); }); diff --git a/api/src/unraid-api/cli/apikey/api-key.command.ts b/api/src/unraid-api/cli/apikey/api-key.command.ts index c40d63200..0ad2069f9 100644 --- a/api/src/unraid-api/cli/apikey/api-key.command.ts +++ b/api/src/unraid-api/cli/apikey/api-key.command.ts @@ -16,6 +16,7 @@ interface KeyOptions { roles?: Role[]; permissions?: Permission[]; overwrite?: boolean; + json?: boolean; } @Command({ @@ -100,28 +101,87 @@ ACTIONS: ${Object.values(AuthAction).join(', ')}`, return true; } - /** Prompt the user to select API keys to delete. Then, delete the selected keys. */ - private async deleteKeys() { + @Option({ + flags: '--json', + description: 'Output machine-readable JSON format', + }) + parseJson(): boolean { + return true; + } + + /** Helper to output either JSON or regular log message */ + private output(message: string, jsonData?: object, jsonOutput?: boolean): void { + if (jsonOutput && jsonData) { + console.log(JSON.stringify(jsonData)); + } else { + this.logger.log(message); + } + } + + /** Helper to output either JSON or regular error message */ + private outputError(message: string, jsonData?: object, jsonOutput?: boolean): void { + if (jsonOutput && jsonData) { + console.log(JSON.stringify(jsonData)); + } else { + this.logger.error(message); + } + } + + /** Delete API keys either by name (non-interactive) or by prompting user selection (interactive). */ + private async deleteKeys(name?: string, jsonOutput?: boolean) { const allKeys = await this.apiKeyService.findAll(); if (allKeys.length === 0) { - this.logger.log('No API keys found to delete'); + this.output( + 'No API keys found to delete', + { deleted: 0, message: 'No API keys found to delete' }, + jsonOutput + ); return; } - const answers = await this.inquirerService.prompt( - DeleteApiKeyQuestionSet.name, - {} - ); - if (!answers.selectedKeys || answers.selectedKeys.length === 0) { - this.logger.log('No keys selected for deletion'); - return; + let selectedKeyIds: string[]; + let deletedKeys: { id: string; name: string }[] = []; + + if (name) { + // Non-interactive mode: delete by name + const keyToDelete = allKeys.find((key) => key.name === name); + if (!keyToDelete) { + this.outputError( + `No API key found with name: ${name}`, + { deleted: 0, error: `No API key found with name: ${name}` }, + jsonOutput + ); + process.exit(1); + } + selectedKeyIds = [keyToDelete.id]; + deletedKeys = [{ id: keyToDelete.id, name: keyToDelete.name }]; + } else { + // Interactive mode: prompt user to select keys + const answers = await this.inquirerService.prompt( + DeleteApiKeyQuestionSet.name, + {} + ); + if (!answers.selectedKeys || answers.selectedKeys.length === 0) { + this.output( + 'No keys selected for deletion', + { deleted: 0, message: 'No keys selected for deletion' }, + jsonOutput + ); + return; + } + selectedKeyIds = answers.selectedKeys; + deletedKeys = allKeys + .filter((key) => selectedKeyIds.includes(key.id)) + .map((key) => ({ id: key.id, name: key.name })); } try { - await this.apiKeyService.deleteApiKeys(answers.selectedKeys); - this.logger.log(`Successfully deleted ${answers.selectedKeys.length} API keys`); + await this.apiKeyService.deleteApiKeys(selectedKeyIds); + const message = `Successfully deleted ${selectedKeyIds.length} API key${selectedKeyIds.length === 1 ? '' : 's'}`; + this.output(message, { deleted: selectedKeyIds.length, keys: deletedKeys }, jsonOutput); } catch (error) { - this.logger.error(error as any); + const errorMessage = error instanceof Error ? error.message : String(error); + this.outputError(errorMessage, { deleted: 0, error: errorMessage }, jsonOutput); process.exit(1); } } @@ -132,13 +192,13 @@ ACTIONS: ${Object.values(AuthAction).join(', ')}`, ): Promise { try { if (options.delete) { - await this.deleteKeys(); + await this.deleteKeys(options.name, options.json); return; } const key = this.apiKeyService.findByField('name', options.name); if (key) { - this.logger.log(key.key); + this.output(key.key, { key: key.key, name: key.name, id: key.id }, options.json); } else if (options.create) { // Check if we have minimum required info from flags (name + at least one role or permission) const hasMinimumInfo = @@ -153,14 +213,20 @@ ACTIONS: ${Object.values(AuthAction).join(', ')}`, // Non-interactive mode - check if key exists and handle overwrite const existingKey = this.apiKeyService.findByField('name', options.name); if (existingKey && !options.overwrite) { - this.logger.error( - `API key with name '${options.name}' already exists. Use --overwrite to replace it.` + this.outputError( + `API key with name '${options.name}' already exists. Use --overwrite to replace it.`, + { + error: `API key with name '${options.name}' already exists. Use --overwrite to replace it.`, + }, + options.json ); process.exit(1); } } - this.logger.log('Creating API Key...'); + if (!options.json) { + this.logger.log('Creating API Key...'); + } if (!options.roles && !options.permissions) { this.logger.error('Please add at least one role or permission to the key.'); @@ -178,7 +244,7 @@ ACTIONS: ${Object.values(AuthAction).join(', ')}`, overwrite: options.overwrite ?? false, }); - this.logger.log(key.key); + this.output(key.key, { key: key.key, name: key.name, id: key.id }, options.json); } else { this.logger.log('No Key Found'); process.exit(1); diff --git a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/ConfigDownload.page b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/ConfigDownload.page deleted file mode 100644 index fd5552066..000000000 --- a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/ConfigDownload.page +++ /dev/null @@ -1,6 +0,0 @@ -Menu="ManagementAccess:160" -Title="API Config Download" -Icon="icon-download" -Tag="download" ---- - \ No newline at end of file diff --git a/web/components/Logs/SingleLogViewer.vue b/web/components/Logs/SingleLogViewer.vue index a52db82fa..48d7ccd79 100644 --- a/web/components/Logs/SingleLogViewer.vue +++ b/web/components/Logs/SingleLogViewer.vue @@ -34,6 +34,7 @@ const state = reactive({ loadedContentChunks: [] as { content: string; startLine: number }[], currentStartLine: undefined as number | undefined, isLoadingMore: false, + isRefreshing: false, isAtTop: false, canLoadMore: false, initialLoadComplete: false, @@ -104,11 +105,25 @@ watch( const effectiveStartLine = startLine || 1; if (state.isLoadingMore) { + // Loading more historical content - prepend to existing chunks state.loadedContentChunks.unshift({ content, startLine: effectiveStartLine }); state.isLoadingMore = false; nextTick(() => (state.canLoadMore = true)); + } else if (state.isRefreshing) { + // Refreshing - replace all content and reset state + state.loadedContentChunks = [{ content, startLine: effectiveStartLine }]; + state.isRefreshing = false; + state.currentStartLine = undefined; + state.isAtTop = false; + state.initialLoadComplete = true; + + nextTick(() => { + forceScrollToBottom(); + setTimeout(() => (state.canLoadMore = true), 300); + }); } else { + // Initial load - replace all content state.loadedContentChunks = [{ content, startLine: effectiveStartLine }]; nextTick(() => { @@ -235,16 +250,6 @@ const downloadLogFile = async () => { } }; -// Clear all state to initial values -const clearState = () => { - state.loadedContentChunks = []; - state.currentStartLine = undefined; - state.isAtTop = false; - state.canLoadMore = false; - state.initialLoadComplete = false; - state.isLoadingMore = false; -}; - // Helper function to start log subscription const startLogSubscription = () => { if (!props.logFilePath) return; @@ -297,10 +302,12 @@ const startLogSubscription = () => { } }; -// Refresh logs +// Refresh logs with full reset const refreshLogContent = async () => { - // Clear the state - clearState(); + // Set refresh flag to indicate we're refreshing + state.isRefreshing = true; + state.isLoadingMore = false; + state.canLoadMore = false; // Refetch with explicit variables to ensure we get the latest logs await refetchLogContent({ @@ -310,13 +317,7 @@ const refreshLogContent = async () => { }); // Restart the subscription with the same variables used for refetch - // Note: subscribeToMore in Vue Apollo doesn't return an unsubscribe function - // The previous subscription is automatically replaced when calling subscribeToMore again startLogSubscription(); - - nextTick(() => { - forceScrollToBottom(); - }); }; watch(() => props.logFilePath, refreshLogContent);