'use strict'; 'require rpc'; 'require uci'; /* Copyright 2026 Docker manager JS for Luci by Paul Donald LICENSE: GPLv2.0 */ const callNetworkInterfaceDump = rpc.declare({ object: 'network.interface', method: 'dump', expect: { 'interface': [] } }); let dockerHosts = null; let dockerHost = null; let localIPv4 = null; let localIPv6 = null; let js_api_available = false; // Load both UCI config and network interfaces in parallel const loadPromise = Promise.all([ callNetworkInterfaceDump(), uci.load('dockerd'), ]).then(([interfaceData]) => { const lan_device = uci.get('dockerd', 'globals', '_luci_lan') || 'lan'; // Find local IPs from network interfaces if (interfaceData) { interfaceData.forEach(iface => { // console.log(iface.up) if (!iface.up || iface.interface !== lan_device) return; // Get IPv4 address if (!localIPv4 && iface['ipv4-address']) { const addr4 = iface['ipv4-address'].find(a => a.address && !a.address.startsWith('127.') ); if (addr4) localIPv4 = addr4.address; } // Get IPv6 address if (!localIPv6) { // Try ipv6-address array first if (iface['ipv6-address']) { const addr6 = iface['ipv6-address'].find(a => a.address && a.address !== '::1' && !a.address.startsWith('fe80:') ); if (addr6) localIPv6 = addr6.address; } // Try ipv6-prefix-assignment if no address found if (!localIPv6 && iface['ipv6-prefix-assignment']) { const prefix = iface['ipv6-prefix-assignment'].find(p => p['local-address'] && p['local-address'].address ); if (prefix) localIPv6 = prefix['local-address'].address; } } }); } dockerHosts = uci.get_first('dockerd', 'globals', 'hosts'); // Find and convert first tcp:// or tcp6:// host const hostsList = Array.isArray(dockerHosts) ? dockerHosts : []; const dh = hostsList.find(h => h && (h.startsWith('tcp://') || h.startsWith('tcp6://') || h.startsWith('inet6://') || h.startsWith('http://') || h.startsWith('https://') )); if (dh) { // const isTcp6 = dh.startsWith('tcp6://'); const protocol = dh.includes(':2376') ? 'https://' : 'http://'; dockerHost = dh.replace(/^(tcp|inet)6?:\/\//, protocol); // Replace 0.0.0.0 or :: with appropriate local IP if (localIPv6) { dockerHost = dockerHost.replace(/\[::1?\]/, `[${localIPv6}]`); // dockerHost = dockerHost.replace(/::/, localIPv6); } if (localIPv4) { dockerHost = dockerHost.replace(/0\.0\.0\.0/, localIPv4); } console.log('Docker configured to use JS API to:', dockerHost); } return dockerHost; }); // Helper to process NDJSON or line-delimited JSON chunks function processLines(buffer, onChunk) { const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (line.trim()) { try { const json = JSON.parse(line); onChunk(json); } catch (e) { onChunk({ raw: line }); } } } return buffer; } function call_docker(method, path, options = {}) { return loadPromise.then(() => { const headers = { ...(options.headers || {}) }; const payload = options.payload || null; const query = options.query || null; const host = dockerHost; const onChunk = options.onChunk || null; // Optional callback for streaming NDJSON const api_ver = uci.get('dockerd', 'globals', 'api_version') || ''; const api_ver_str = api_ver ? `/${api_ver}` : ''; if (!host) { return Promise.reject(new Error('Docker host not configured')); } // Check if WebSocket upgrade is requested const isWebSocketUpgrade = headers['Connection']?.toLowerCase() === 'upgrade' || headers['connection']?.toLowerCase() === 'upgrade'; if (isWebSocketUpgrade) { return createWebSocketConnection(host, path, query); } // Build URL let url = `${host}${api_ver_str}${path}`; if (query) { const params = new URLSearchParams(); for (const key in query) { if (query[key] != null) { params.append(key, query[key]); } } // dockerd does not like encoded params here. const queryString = params.toString(); if (queryString) { url += `?${queryString}`; } } // Build fetch options const fetchOptions = { method, headers: { ...headers // Always include custom headers }, }; if (payload) { fetchOptions.body = JSON.stringify(payload); if (!fetchOptions.headers['Content-Type']) { fetchOptions.headers['Content-Type'] = 'application/json'; } } // Make the request return fetch(url, fetchOptions) .then(response => { // If streaming callback provided, use streaming response if (onChunk) { const reader = response.body?.getReader(); const decoder = new TextDecoder(); let buffer = ''; return new Promise((resolve) => { const processStream = async () => { try { while (true) { const { done, value } = await reader.read(); if (done) { // Process any remaining data in buffer buffer = processLines(buffer, onChunk); break; } // Decode chunk and add to buffer buffer += decoder.decode(value, { stream: true }); // Use generic processor for NDJSON/line chunks buffer = processLines(buffer, onChunk); } // Return final response resolve({ code: response.status, headers: response.headers }); } catch (err) { console.error('Streaming error:', err); throw err; } }; processStream(); }); } // Normal buffered response if (response?.status >= 304) { console.error(`HTTP ${response.status}: ${response.statusText}`); } const headersObj = {}; for (const [key, value] of response.headers.entries()) { headersObj[key] = value; } return response.text().then(text => { const safeText = (typeof text === 'string') ? text : ''; let parsed = safeText || text; const contentType = response.headers.get('content-type') || ''; // Try normal JSON parse first try { parsed = JSON.parse(text); } catch (err) { // If the payload is newline-delimited JSON (Docker events), split and parse each line if (['application/json', 'application/x-ndjson', 'application/json-seq'].includes(contentType) || safeText.includes('\n')) { const lines = safeText.split(/\r?\n/).filter(Boolean); try { parsed = lines.map(l => JSON.parse(l)); } catch (err2) { // Fall back to raw text if parsing fails parsed = text; } } } return { code: response.status, body: parsed, headers: headersObj, }; }); }) .catch(error => { console.error('Docker API error:', error); }); }); } function createWebSocketConnection(host, path, query) { return new Promise((resolve, reject) => { try { // Convert http/https to ws/wss const wsHost = host .replace(/^https:/, 'wss:') .replace(/^http:/, 'ws:'); // Build WebSocket URL let wsUrl = `${wsHost}${path}`; if (query) { const params = new URLSearchParams(); for (const key in query) { if (query[key] != null) { params.append(key, query[key]); } } const queryString = params.toString(); if (queryString) { wsUrl += `?${queryString}`; } } console.log('Opening WebSocket connection to:', wsUrl); const ws = new WebSocket(wsUrl); let resolved = false; // Handle connection open ws.onopen = () => { console.log('WebSocket connected'); if (!resolved) { resolved = true; // Return a Response-like object with WebSocket support resolve({ ok: true, status: 200, statusText: 'OK', headers: new Map(), body: ws, ws: ws, // Add helper for sending messages send: (data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(data); } }, // Add helper for receiving messages as async iterator async *[Symbol.asyncIterator]() { while (ws.readyState === WebSocket.OPEN) { yield new Promise((res, rej) => { const messageHandler = (event) => { ws.removeEventListener('message', messageHandler); ws.removeEventListener('error', errorHandler); res(event.data); }; const errorHandler = (error) => { ws.removeEventListener('message', messageHandler); ws.removeEventListener('error', errorHandler); rej(error); }; ws.addEventListener('message', messageHandler); ws.addEventListener('error', errorHandler); }); } } }); } }; // Handle connection error ws.onerror = (error) => { console.error('WebSocket error:', error); if (!resolved) { resolved = true; reject(new Error(`WebSocket connection failed: ${error.message || 'Unknown error'}`)); } }; // Handle close (including handshake failures) ws.onclose = (event) => { console.log('WebSocket closed'); if (!resolved) { resolved = true; reject(new Error(`WebSocket closed before open (${event?.code || 'unknown'})`)); } }; } catch (error) { reject(error); } }); } const core_methods = { version: { call: () => call_docker('GET', '/version') }, info: { call: () => call_docker('GET', '/info') }, ping: { call: () => call_docker('GET', '/_ping') }, df: { call: () => call_docker('GET', '/system/df') }, events: { args: { query: { 'since': '', 'until': `${Date.now()}`, 'filters': '' } }, call: (request) => call_docker('GET', '/events', { query: request?.query, onChunk: request?.onChunk }) }, }; /* const exec_methods = { start: { args: { id: '', body: '' }, call: (request) => call_docker('POST', `/exec/${request?.id}/start`, { payload: request?.body }) }, resize: { args: { id: '', query: { 'h': 0, 'w': 0 } }, call: (request) => call_docker('POST', `/exec/${request?.id}/resize`, { query: request?.query }) }, inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/exec/${request?.id}/json`) }, }; */ const container_methods = { list: { args: { query: { 'all': false, 'limit': false, 'size': false, 'filters': '' } }, call: (request) => call_docker('GET', '/containers/json', { query: request?.query }) }, create: { args: { query: { 'name': '', 'platform': '' }, body: {} }, call: (request) => call_docker('POST', '/containers/create', { query: request?.query, payload: request?.body }) }, inspect: { args: { id: '', query: { 'size': false } }, call: (request) => call_docker('GET', `/containers/${request?.id}/json`, { query: request?.query }) }, top: { args: { id: '', query: { 'ps_args': '' } }, call: (request) => call_docker('GET', `/containers/${request?.id}/top`, { query: request?.query }) }, logs: { args: { id: '', query: {} }, call: (request) => call_docker('GET', `/containers/${request?.id}/logs`, { query: request?.query, onChunk: request?.onChunk }) }, changes: { args: { id: '' }, call: (request) => call_docker('GET', `/containers/${request?.id}/changes`) }, export: { args: { id: '' }, call: (request) => call_docker('GET', `/containers/${request?.id}/export`) }, stats: { args: { id: '', query: { 'stream': false, 'one-shot': false } }, call: (request) => call_docker('GET', `/containers/${request?.id}/stats`, { query: request?.query }) }, resize: { args: { id: '', query: { 'h': 0, 'w': 0 } }, call: (request) => call_docker('POST', `/containers/${request?.id}/resize`, { query: request?.query }) }, start: { args: { id: '', query: { 'detachKeys': '' } }, call: (request) => call_docker('POST', `/containers/${request?.id}/start`, { query: request?.query }) }, stop: { args: { id: '', query: { 'signal': '', 't': 0 } }, call: (request) => call_docker('POST', `/containers/${request?.id}/stop`, { query: request?.query }) }, restart: { args: { id: '', query: { 'signal': '', 't': 0 } }, call: (request) => call_docker('POST', `/containers/${request?.id}/restart`, { query: request?.query }) }, kill: { args: { id: '', query: { 'signal': '' } }, call: (request) => call_docker('POST', `/containers/${request?.id}/kill`, { query: request?.query }) }, update: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/containers/${request?.id}/update`, { payload: request?.body }) }, rename: { args: { id: '', query: { 'name': '' } }, call: (request) => call_docker('POST', `/containers/${request?.id}/rename`, { query: request?.query }) }, pause: { args: { id: '' }, call: (request) => call_docker('POST', `/containers/${request?.id}/pause`) }, unpause: { args: { id: '' }, call: (request) => call_docker('POST', `/containers/${request?.id}/unpause`) }, // attach // attach websocket attach_ws: { args: { id: '' }, call: (request) => call_docker('GET', `/containers/${request?.id}/attach/ws`, { query: request?.query, headers: { 'Connection': 'Upgrade' } }) }, // wait remove: { args: { id: '', query: { 'v': false, 'force': false, 'link': false } }, call: (request) => call_docker('DELETE', `/containers/${request?.id}`, { query: request?.query }) }, // archive info info_archive: { args: { id: '', query: { 'path': '' } }, call: (request) => call_docker('HEAD', `/containers/${request?.id}/archive`, { query: request?.query }) }, // archive get get_archive: { args: { id: '', query: { 'path': '' } }, call: (request) => call_docker('GET', `/containers/${request?.id}/archive`, { query: request?.query }) }, // archive extract put_archive: { args: { id: '', query: { 'path': '', 'noOverwriteDirNonDir': '', 'copyUIDGID': '' }, body: '' }, call: (request) => call_docker('PUT', `/containers/${request?.id}/archive`, { query: request?.query, payload: request?.body }) }, exec: { args: { id: '', opts: {} }, call: (request) => call_docker('POST', `/containers/${request?.id}/exec`, { payload: request?.opts }) }, prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/containers/prune', { query: request?.query }) }, // Not a docker command - but a local command to invoke ttyd so our browser can open websocket to docker // ttyd_start: { args: { id: '', cmd: '/bin/sh', port: 7682, uid: '' }, call: (request) => run_ttyd(request) }, }; const image_methods = { list: { args: { query: { 'all': false, 'digests': false, 'shared-size': false, 'manifests': false, 'filters': '' } }, call: (request) => call_docker('GET', '/images/json', { query: request?.query }) }, build: { args: { query: { '': '' }, headers: {}, onChunk: null }, call: (request) => call_docker('POST', '/build', { query: request?.query, headers: request?.headers, onChunk: request?.onChunk }) }, build_prune: { args: { query: { '': '' }, headers: {}, onChunk: null }, call: (request) => call_docker('POST', '/build/prune', { query: request?.query, headers: request?.headers, onChunk: request?.onChunk }) }, create: { args: { query: { '': '' }, headers: {}, onChunk: null }, call: (request) => call_docker('POST', '/images/create', { query: request?.query, headers: request?.headers, onChunk: request?.onChunk }) }, inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request?.id}/json`) }, history: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request?.id}/history`) }, push: { args: { name: '', query: { tag: '', platform: '' }, headers: {}, onChunk: null }, call: (request) => call_docker('POST', `/images/${request?.name}/push`, { query: request?.query, headers: request?.headers, onChunk: request?.onChunk }) }, tag: { args: { id: '', query: { 'repo': '', 'tag': '' } }, call: (request) => call_docker('POST', `/images/${request?.id}/tag`, { query: request?.query }) }, remove: { args: { id: '', query: { 'force': false, 'noprune': false }, onChunk: null }, call: (request) => call_docker('DELETE', `/images/${request?.id}`, { query: request?.query, onChunk: request?.onChunk }) }, search: { args: { query: { 'term': '', 'limit': 0, 'filters': '' } }, call: (request) => call_docker('GET', '/images/search', { query: request?.query }) }, prune: { args: { query: { 'filters': '' }, onChunk: null }, call: (request) => call_docker('POST', '/images/prune', { query: request?.query, onChunk: request?.onChunk }) }, // create/commit get: { args: { id: '', onChunk: null }, call: (request) => call_docker('GET', `/images/${request?.id}/get`, { onChunk: request?.onChunk }) }, // get == export several load: { args: { query: { 'quiet': false }, onChunk: null }, call: (request) => call_docker('POST', '/images/load', { query: request?.query, onChunk: request?.onChunk }) }, }; const network_methods = { list: { args: { query: { 'filters': '' } }, call: (request) => call_docker('GET', '/networks', { query: request?.query }) }, inspect: { args: { id: '', query: { 'verbose': false, 'scope': '' } }, call: (request) => call_docker('GET', `/networks/${request?.id}`, { query: request?.query }) }, remove: { args: { id: '' }, call: (request) => call_docker('DELETE', `/networks/${request?.id}`) }, create: { args: { body: {} }, call: (request) => call_docker('POST', '/networks/create', { payload: request?.body }) }, connect: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/networks/${request?.id}/connect`, { payload: request?.body }) }, disconnect: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/networks/${request?.id}/disconnect`, { payload: request?.body }) }, prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/networks/prune', { query: request?.query }) }, }; const volume_methods = { list: { args: { query: { 'filters': '' } }, call: (request) => call_docker('GET', '/volumes', { query: request?.query }) }, create: { args: { opts: {} }, call: (request) => call_docker('POST', '/volumes/create', { payload: request?.opts }) }, inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/volumes/${request?.id}`) }, update: { args: { id: '', query: { 'version': 0 }, spec: {} }, call: (request) => call_docker('PUT', `/volumes/${request?.id}`, { query: request?.query, payload: request?.spec }) }, remove: { args: { id: '', query: { 'force': false } }, call: (request) => call_docker('DELETE', `/volumes/${request?.id}`, { query: request?.query }) }, prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/volumes/prune', { query: request?.query }) }, }; // const methods = { // 'docker': core_methods, // 'docker.container': container_methods, // 'docker.exec': exec_methods, // 'docker.image': image_methods, // 'docker.network': network_methods, // 'docker.volume': volume_methods, // }; // Determine JS API availability after core methods are ready const apiAvailabilityPromise = loadPromise.then(() => { if (!dockerHost) { js_api_available = false; return [js_api_available, dockerHost]; } return core_methods.ping.call() .then(res => { // ping returns raw 'OK' text; treat any truthy/OK as success const body = res?.body; js_api_available = body === 'OK'; return [js_api_available, dockerHost]; }) .catch(error => { console.warn('JS API unavailable (likely CORS or network):', error?.message || error); js_api_available = false; return [js_api_available, dockerHost]; }); }); return L.Class.extend({ js_api_available: () => apiAvailabilityPromise.then(() => [js_api_available, dockerHost]), container_attach_ws: container_methods.attach_ws.call, container_changes: container_methods.changes.call, container_create: container_methods.create.call, // container_export: container_export, // use controller instead container_info_archive: container_methods.info_archive.call, container_inspect: container_methods.inspect.call, container_kill: container_methods.kill.call, container_list: container_methods.list.call, container_logs: container_methods.logs.call, container_pause: container_methods.pause.call, container_prune: container_methods.prune.call, container_remove: container_methods.remove.call, container_rename: container_methods.rename.call, container_restart: container_methods.restart.call, container_start: container_methods.start.call, container_stats: container_methods.stats.call, container_stop: container_methods.stop.call, container_top: container_methods.top.call, // container_ttyd_start: container_methods.ttyd_start.call, container_unpause: container_methods.unpause.call, container_update: container_methods.update.call, docker_version: core_methods.version.call, docker_info: core_methods.info.call, docker_ping: core_methods.ping.call, docker_df: core_methods.df.call, docker_events: core_methods.events.call, image_build: image_methods.build.call, image_create: image_methods.create.call, image_history: image_methods.history.call, image_inspect: image_methods.inspect.call, image_list: image_methods.list.call, image_prune: image_methods.prune.call, image_push: image_methods.push.call, image_remove: image_methods.remove.call, image_tag: image_methods.tag.call, network_connect: network_methods.connect.call, network_create: network_methods.create.call, network_disconnect: network_methods.disconnect.call, network_inspect: network_methods.inspect.call, network_list: network_methods.list.call, network_prune: network_methods.prune.call, network_remove: network_methods.remove.call, volume_create: volume_methods.create.call, volume_inspect: volume_methods.inspect.call, volume_list: volume_methods.list.call, volume_prune: volume_methods.prune.call, volume_remove: volume_methods.remove.call, });