diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 0537d1166..bd8ae108c 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 0aa45b327..f74047f4e 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/global.d.ts b/netbox/project-static/src/global.d.ts index ad56feba8..05fda72b0 100644 --- a/netbox/project-static/src/global.d.ts +++ b/netbox/project-static/src/global.d.ts @@ -11,12 +11,15 @@ type APIAnswer = { results: T[]; }; -type APIError = { +type ErrorBase = { error: string; +}; + +type APIError = { exception: string; netbox_version: string; python_version: string; -}; +} & ErrorBase; type APIObjectBase = { id: number; @@ -39,6 +42,23 @@ type APIReference = { _depth: number; }; +type APISecret = { + assigned_object: APIObjectBase; + assigned_object_id: number; + assigned_object_type: string; + created: string; + custom_fields: Record; + display: string; + hash: string; + id: number; + last_updated: string; + name: string; + plaintext: Nullable; + role: APIObjectBase; + tags: number[]; + url: string; +}; + interface ObjectWithGroup extends APIObjectBase { group: Nullable; } diff --git a/netbox/project-static/src/netbox.ts b/netbox/project-static/src/netbox.ts index a8d9023fa..465ecdf9f 100644 --- a/netbox/project-static/src/netbox.ts +++ b/netbox/project-static/src/netbox.ts @@ -7,7 +7,7 @@ import { initSpeedSelector, initForms } from './forms'; import { initRackElevation } from './buttons'; import { initClipboard } from './clipboard'; import { initSearchBar } from './search'; -// import { initGenerateKeyPair } from './secrets'; +import { initGenerateKeyPair, initLockUnlock, initGetSessionKey } from './secrets'; import { getElements } from './util'; const INITIALIZERS = [ @@ -21,7 +21,9 @@ const INITIALIZERS = [ initColorSelect, initRackElevation, initClipboard, - // initGenerateKeyPair, + initGenerateKeyPair, + initLockUnlock, + initGetSessionKey, ] as (() => void)[]; /** @@ -35,7 +37,6 @@ function initBootstrap(): void { new Tooltip(tooltip, { container: 'body', boundary: 'window' }); } for (const modal of getElements('[data-bs-toggle="modal"]')) { - // for (const modal of getElements('div.modal')) { new Modal(modal); } initMessageToasts(); diff --git a/netbox/project-static/src/secrets.ts b/netbox/project-static/src/secrets.ts index 88c255fc7..d2104f959 100644 --- a/netbox/project-static/src/secrets.ts +++ b/netbox/project-static/src/secrets.ts @@ -1,47 +1,50 @@ -import { apiGetBase, getElements, isApiError } from './util'; +import { Modal } from 'bootstrap'; +import { apiGetBase, apiPostForm, getElements, isApiError, hasError } from './util'; +import { createToast } from './toast'; + /** - * - * $('#generate_keypair').click(function() { - $('#new_keypair_modal').modal('show'); - $.ajax({ - url: netbox_api_path + 'secrets/generate-rsa-key-pair/', - type: 'GET', - dataType: 'json', - success: function (response, status) { - var public_key = response.public_key; - var private_key = response.private_key; - $('#new_pubkey').val(public_key); - $('#new_privkey').val(private_key); - }, - error: function (xhr, ajaxOptions, thrownError) { - alert("There was an error generating a new key pair."); - } - }); - }); + * Initialize Generate Private Key Pair Elements. */ export function initGenerateKeyPair() { const element = document.getElementById('new_keypair_modal') as HTMLDivElement; const accept = document.getElementById('use_new_pubkey') as HTMLButtonElement; + // If the elements are not loaded, stop. + if (element === null || accept === null) { + return; + } const publicElem = element.querySelector('textarea#new_pubkey'); const privateElem = element.querySelector('textarea#new_privkey'); + /** + * Handle Generate Private Key Pair Modal opening. + */ function handleOpen() { + // When the modal opens, set the `readonly` attribute on the textarea elements. for (const elem of [publicElem, privateElem]) { if (elem !== null) { elem.setAttribute('readonly', ''); } } - + // Fetch the key pair from the API. apiGetBase('/api/secrets/generate-rsa-key-pair').then(data => { - if (!isApiError(data)) { + if (!hasError(data)) { + // If key pair generation was successful, set the textarea elements' value to the generated + // values. const { private_key: priv, public_key: pub } = data; if (publicElem !== null && privateElem !== null) { publicElem.value = pub; privateElem.value = priv; } + } else { + // Otherwise, show an error. + const toast = createToast('danger', 'Error', data.error); + toast.show(); } }); } + /** + * Set the public key form field's value to the generated public key. + */ function handleAccept() { const publicKeyField = document.getElementById('id_public_key') as HTMLTextAreaElement; if (publicElem !== null) { @@ -53,10 +56,147 @@ export function initGenerateKeyPair() { accept.addEventListener('click', handleAccept); } -export function initLockUnlock() { - for (const element of getElements('button.unlock-secret')) { - function handleClick() { - const { secretId } = element.dataset; - } +/** + * Toggle copy/lock/unlock button visibility based on the action occurring. + * @param id Secret ID. + * @param action Lock or Unlock, so we know which buttons to display. + */ +function toggleSecretButtons(id: string, action: 'lock' | 'unlock') { + const unlockButton = document.querySelector(`button.unlock-secret[secret-id='${id}']`); + const lockButton = document.querySelector(`button.lock-secret[secret-id='${id}']`); + const copyButton = document.querySelector(`button.copy-secret[secret-id='${id}']`); + + // If we're unlocking, hide the unlock button. Otherwise, show it. + if (unlockButton !== null) { + if (action === 'unlock') unlockButton.classList.add('d-none'); + if (action === 'lock') unlockButton.classList.remove('d-none'); + } + // If we're unlocking, show the lock button. Otherwise, hide it. + if (lockButton !== null) { + if (action === 'unlock') lockButton.classList.remove('d-none'); + if (action === 'lock') lockButton.classList.add('d-none'); + } + // If we're unlocking, show the copy button. Otherwise, hide it. + if (copyButton !== null) { + if (action === 'unlock') copyButton.classList.remove('d-none'); + if (action === 'lock') copyButton.classList.add('d-none'); + } +} + +/** + * Initialize Lock & Unlock button event listeners & callbacks. + */ +export function initLockUnlock() { + const privateKeyModalElem = document.getElementById('privkey_modal'); + if (privateKeyModalElem === null) { + return; + } + const privateKeyModal = new Modal(privateKeyModalElem); + + /** + * Unlock a secret, or prompt the user for their private key, if a session key is not available. + * + * @param id Secret ID + */ + function unlock(id: string | null) { + const target = document.getElementById(`secret_${id}`); + if (typeof id === 'string' && id !== '') { + apiGetBase(`/api/secrets/secrets/${id}`).then(data => { + if (!hasError(data)) { + const { plaintext } = data; + // `plaintext` is the plain text value of the secret. If it is null, it has not been + // decrypted, likely due to a mission session key. + + if (target !== null && plaintext !== null) { + // If `plaintext` is not null, we have the decrypted value. Set the target element's + // inner text to the decrypted value and toggle copy/lock button visibility. + target.innerText = plaintext; + toggleSecretButtons(id, 'unlock'); + } else { + // Otherwise, we do _not_ have the decrypted value and need to prompt the user for + // their private RSA key, in order to get a session key. The session key is then sent + // as a cookie in future requests. + privateKeyModal.show(); + } + } else { + if (data.error.toLowerCase().includes('invalid session key')) { + // If, for some reason, a request was made but resulted in an API error that complains + // of a missing session key, prompt the user for their session key. + privateKeyModal.show(); + } else { + // If we received an API error but it doesn't contain 'invalid session key', show the + // user an error message. + const toast = createToast('danger', 'Error', data.error); + toast.show(); + } + } + }); + } + } + /** + * Lock a secret and toggle visibility of the unlock button. + * @param id Secret ID + */ + function lock(id: string | null) { + if (typeof id === 'string' && id !== '') { + const target = document.getElementById(`secret_${id}`); + if (target !== null) { + // Obscure the inner text of the secret element. + target.innerText = '********'; + } + // Toggle visibility of the copy/lock/unlock buttons. + toggleSecretButtons(id, 'lock'); + } + } + + for (const element of getElements('button.unlock-secret')) { + element.addEventListener('click', () => unlock(element.getAttribute('secret-id'))); + } + for (const element of getElements('button.lock-secret')) { + element.addEventListener('click', () => lock(element.getAttribute('secret-id'))); + } +} + +/** + * Request a session key from the API. + * @param privateKey RSA Private Key (valid JSON string) + */ +function requestSessionKey(privateKey: string) { + apiPostForm('/api/secrets/get-session-key/', { private_key: privateKey }).then(res => { + if (!hasError(res)) { + // If the response received was not an error, show the user a success message. + const toast = createToast('success', 'Session Key Received', 'You may now unlock secrets.'); + toast.show(); + } else { + // Otherwise, show the user an error message. + let message = res.error; + if (isApiError(res)) { + // If the error received was a standard API error containing a Python exception message, + // append it to the error. + message += `\n${res.exception}`; + } + const toast = createToast('danger', 'Failed to Retrieve Session Key', message); + toast.show(); + } + }); +} + +/** + * Initialize Request Session Key Elements. + */ +export function initGetSessionKey() { + for (const element of getElements('#request_session_key')) { + /** + * Send the user's input private key to the API to get a session key, which will be stored as + * a cookie for future requests. + */ + function handleClick() { + for (const pk of getElements('#user_privkey')) { + requestSessionKey(pk.value); + // Clear the private key form field value. + pk.value = ''; + } + } + element.addEventListener('click', handleClick); } } diff --git a/netbox/project-static/src/util.ts b/netbox/project-static/src/util.ts index b8cf37fdd..960fd30e2 100644 --- a/netbox/project-static/src/util.ts +++ b/netbox/project-static/src/util.ts @@ -1,6 +1,9 @@ import Cookie from 'cookie'; - export function isApiError(data: Record): data is APIError { + return 'error' in data && 'exception' in data; +} + +export function hasError(data: Record): data is ErrorBase { return 'error' in data; } @@ -34,13 +37,55 @@ export function getCsrfToken(): string { export async function apiGetBase>( url: string, -): Promise { +): Promise { const token = getCsrfToken(); const res = await fetch(url, { method: 'GET', headers: { 'X-CSRFToken': token }, + credentials: 'same-origin', }); + const contentType = res.headers.get('Content-Type'); + if (typeof contentType === 'string' && contentType.includes('text')) { + const error = await res.text(); + return { error } as ErrorBase; + } + const json = (await res.json()) as T | APIError; + if (!res.ok && Array.isArray(json)) { + const error = json.join('\n'); + return { error } as ErrorBase; + } + return json; +} + +export async function apiPostForm< + T extends Record, + R extends Record +>(url: string, data: T): Promise { + const token = getCsrfToken(); + const body = new URLSearchParams(); + for (const [k, v] of Object.entries(data)) { + body.append(k, String(v)); + } + const res = await fetch(url, { + method: 'POST', + body, + headers: { 'X-CSRFToken': token }, + }); + + const contentType = res.headers.get('Content-Type'); + if (typeof contentType === 'string' && contentType.includes('text')) { + let error = await res.text(); + if (contentType.includes('text/html')) { + error = res.statusText; + } + return { error } as ErrorBase; + } + + const json = (await res.json()) as R | APIError; + if (!res.ok && 'detail' in json) { + return { error: json.detail as string } as ErrorBase; + } return json; } @@ -50,7 +95,7 @@ export async function apiGetBase>( */ export async function getApiData( url: string, -): Promise | APIError> { +): Promise | ErrorBase | APIError> { return await apiGetBase>(url); } diff --git a/netbox/templates/secrets/inc/private_key_modal.html b/netbox/templates/secrets/inc/private_key_modal.html index 5b1d4550b..86f240277 100644 --- a/netbox/templates/secrets/inc/private_key_modal.html +++ b/netbox/templates/secrets/inc/private_key_modal.html @@ -2,26 +2,26 @@ diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html index b79fc5026..c84a6c342 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -53,15 +53,15 @@
Secret
-
********
+
********
- -
diff --git a/netbox/templates/secrets/secret_import.html b/netbox/templates/secrets/secret_import.html index 0e540eb94..e8789da66 100644 --- a/netbox/templates/secrets/secret_import.html +++ b/netbox/templates/secrets/secret_import.html @@ -5,7 +5,3 @@ {{ block.super }} {% include 'secrets/inc/private_key_modal.html' %} {% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/secrets/secretrole.html b/netbox/templates/secrets/secretrole.html index 0e284fb75..2116d8a3c 100644 --- a/netbox/templates/secrets/secretrole.html +++ b/netbox/templates/secrets/secretrole.html @@ -3,33 +3,35 @@ {% load plugins %} {% block breadcrumbs %} -
  • Secret Roles
  • -
  • {{ object }}
  • + + {% endblock %} {% block content %} -
    +
    -
    -
    - Secret Role +
    +
    + Secret Role +
    +
    + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Secrets + {{ secrets_table.rows|length }} +
    - - - - - - - - - - - - - -
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Secrets - {{ secrets_table.rows|length }} -
    {% plugin_left_page object %}
    @@ -40,15 +42,17 @@
    -
    -
    - Secrets +
    +
    + Secrets +
    +
    + {% include 'inc/table.html' with table=secrets_table %}
    - {% include 'inc/table.html' with table=secrets_table %} {% if perms.secrets.add_secret %} -