#!/usr/bin/env ucode // Copyright 2025 Paul Donald / luci-lib-docker-js // Licensed to the public under the Apache License 2.0. // Built against the docker v1.47 API 'use strict'; import * as http from 'luci.http'; import * as fs from 'fs'; import * as socket from 'socket'; import { cursor } from 'uci'; import * as ds from 'luci.docker_socket'; // const cmdline = fs.readfile('/proc/self/cmdline'); // const args = split(cmdline, '\0'); const caller = trim(fs.readfile('/proc/self/comm')); const BLOCKSIZE = 8192; const POLL_TIMEOUT = 8000; // default; can be overridden per request // const API_VER = '/v1.47'; const PROTOCOL = 'HTTP/1.1'; const CLIENT_VER = '1'; function merge(a, b) { let c = {}; for (let k, v in a) c[k] = v; for (let k, v in b) c[k] = v; return c; }; function chunked_body_reader(sock, initial_buffer) { let state = 0, chunklen = 0, buffer = initial_buffer || ''; function poll_and_recv() { let ready = socket.poll(POLL_TIMEOUT, [sock, socket.POLLIN]); if (!ready || !length(ready)) return null; let data = sock.recv(BLOCKSIZE); if (!data) return null; buffer += data; return true; } return () => { while (true) { if (state === 0) { let m = match(buffer, /^([0-9a-fA-F]+)\r\n/); if (!m || length(m) < 2) { if (!poll_and_recv()) return null; continue; } chunklen = int(m[1], 16); buffer = substr(buffer, length(m[0])); if (chunklen === 0) return null; state = 1; } if (state === 1 && length(buffer) >= chunklen + 2) { let chunk = substr(buffer, 0, chunklen); buffer = substr(buffer, chunklen + 2); state = 0; return chunk; } else { if (!poll_and_recv()) return null; continue; } } }; }; function read_http_headers(response_headers, response) { const lines = split(response, /\r?\n/); for (let l in lines) { let kv = match(l, /([^:]+):\s*(.*)/); if (kv && length(kv) === 3) response_headers[lc(kv[1])] = kv[2]; } return response_headers; }; function get_api_ver() { const ctx = cursor(); const version = ctx.get('dockerd', 'globals', 'api_version') || ''; const version_str = version ? `/${version}` : ''; ctx.unload(); return version_str; }; function coerce_values_to_string(obj) { for (let k, v in obj) { v = `${v}`; obj[k]=v; } return obj; }; function call_docker(method, path, options) { options = options || {}; const headers = options.headers || {}; let payload = options.payload || null; /* requires ucode 2026-01-16 if get_socket_dest() provides ip:port e.g. '127.0.0.1:2375'. We use get_socket_dest_compat() which builds the SockAddress manually to avoid this. Important: dockerd after v28 won't accept tcp://x.x.x.x:2375 without --tls* options. A solution is a reverse proxy or ssh port forwarding to a remote host that uses the unix socket, and you still connect to a 'local port', or socket: ssh -L /tmp/docker.sock:localhost:2375 user@remote-host (openssh-client) or (dropbear) socat TCP-LISTEN:12375,reuseaddr,fork UNIX-CONNECT:/var/run/docker.sock ssh -L 2375:localhost:12375 user@remote-host */ /* works on ucode 2025-12-01 */ const sock_dest = ds.get_socket_dest_compat(); const sock = socket.create(sock_dest.family, socket.SOCK_STREAM); /* works on ucode 2026-01-16 */ // const sock_dest = ds.get_socket_dest(); // const sock_addr = socket.sockaddr(sock_dest); // const sock = socket.create(sock_addr.family, socket.SOCK_STREAM); if (caller != 'rpcd') { print('sock_dest:', sock_dest, '\n'); // print('sock_addr:', sock_addr, '\n'); } if (!sock) { return { code: 500, headers: {}, body: { message: "Failed to create socket" } }; } let conn_result = sock.connect(sock_dest); let err_msg = `Failed to connect to docker host at ${sock_dest}`; if (!conn_result) { sock.close(); return { code: 500, headers: {}, body: { message: err_msg} }; } if (caller != 'rpcd') print("query: ", options.query, '\n'); const query = options.query ? http.build_querystring(coerce_values_to_string(options.query)) : ''; const url = path + query; const req_headers = [ `${method} ${get_api_ver()}${url} ${PROTOCOL}`, `Host: luci-host`, `User-Agent: luci-app-dockerman-rpc-ucode/${CLIENT_VER}`, `Connection: close` ]; if (payload) { if (type(payload) === 'object') { payload = sprintf('%J', payload); headers['Content-Type'] = 'application/json'; } headers['Content-Length'] = '' + length(payload); } for (let k, v in headers) push(req_headers, `${k}: ${v}`); push(req_headers, '', ''); if (caller != 'rpcd') print(join('\r\n', req_headers), "\n"); sock.send(join('\r\n', req_headers)); if (payload) sock.send(payload); const response_buff = sock.recv(BLOCKSIZE); if (!response_buff || response_buff === '') { sock.close(); return { code: 500, headers: {}, body: { message: "No response from Docker socket" } }; } const response_parts = split(response_buff, /\r?\n\r?\n/, 2); const response_headers = read_http_headers({}, response_parts[0]); let response_body; let is_chunked = (response_headers['transfer-encoding'] === 'chunked'); let reader; if (is_chunked) { reader = chunked_body_reader(sock, response_parts[1]); } else if (response_headers['content-length']) { let content_length = int(response_headers['content-length']); let buf = response_parts[1]; reader = () => { if (content_length <= 0) return null; if (buf && length(buf)) { let chunk = substr(buf, 0, content_length); buf = substr(buf, length(chunk)); content_length -= length(chunk); return chunk; } let data = sock.recv(min(BLOCKSIZE, content_length)); if (!data || data === '') return null; content_length -= length(data); return data; }; } else { // Fallback for HTTP/1.0 or no content-length: read until close or timeout reader = () => { // Poll with 2 second timeout let ready = socket.poll(POLL_TIMEOUT, [sock, socket.POLLIN]); if (!ready || !length(ready)) return null; // Timeout or error let data = sock.recv(BLOCKSIZE); if (!data || data === '') return null; return data; }; } let chunks = [], chunk; while ((chunk = reader())) { push(chunks, chunk); } sock.close(); response_body = join('', chunks); // Parse HTTP status code let status_line = split(response_parts[0], /\r?\n/)[0]; let status_match = match(status_line, /HTTP\/\S+\s+(\d+)/); let code = status_match ? int(status_match[1]) : 0; // Docker events endpoint returns newline-delimited JSON, not a single JSON object if (response_headers['content-type'] === 'application/json' && response_body) { // Single JSON object let data; try { data = json(rtrim(response_body)); } catch { data = null; } // Check if this is newline-delimited JSON (multiple lines with JSON objects) if (!data) { // Parse each line as a separate JSON object let lines = split(trim(response_body), /\n/); let events = []; for (let line in lines) { line = trim(line); if (line) { try { push(events, json(line)); } catch { /* skip invalid lines */ } } } response_body = events; } else { response_body = data; } } return { code: code, headers: response_headers, body: response_body }; }; function run_ttyd(request) { const id = request.args.id || ''; const cmd = request.args.cmd || '/bin/sh'; const port = request.args.port || 7682; const uid = request.args.uid || ''; if (!id) { return { error: 'Container ID is required' }; } let ttyd_cmd = `ttyd -q -d 2 --once --writable -p ${port} docker`; const sock_addr = ds.get_socket_dest(); /* Build the full command: ttyd --writable -d 2 --once -p PORT docker -H unix://SOCKET exec -it [-u UID] CONTAINER CMD if the socket is /var/run/docker.sock, prefix unix:// Note: invocations of docker -H x.x.x.x:2375 [..] will fail after v27 without --tls* */ const sock_str = index(sock_addr, '/') != -1 && index(sock_addr, 'unix://') == -1 ? 'unix://' + sock_addr : sock_addr; ttyd_cmd = `${ttyd_cmd} -H "${sock_str}" exec -it`; if (uid && uid !== '') { ttyd_cmd = `${ttyd_cmd} -u ${uid}`; } ttyd_cmd = `${ttyd_cmd} ${id} ${cmd} &`; // Try to kill any existing ttyd processes on this port system(`pkill -f "ttyd.*-p ${port}"` + ' 2>/dev/null; true'); // Start ttyd system(ttyd_cmd); return { status: 'ttyd started', command: ttyd_cmd }; } // https://docs.docker.com/reference/api/engine/version/v1.47/ /* Note: methods here are included for structural reference. Some rpcd methods are not suitable to be called from the GUI because they are streaming endpoints or the operations in a busy dockerd cluster take a *long* time which causes timeouts at the front end. Good examples of this are: - /system/df - push - pull - all /prune We include them here because they can be useful from the command line. */ 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': `${time()}`, 'filters': '' } }, call: (request) => call_docker('GET', '/events', { query: request?.args?.query }) }, }; const exec_methods = { start: { args: { id: '', body: '' }, call: (request) => call_docker('POST', `/exec/${request.args.id}/start`, { payload: request.args.body }) }, resize: { args: { id: '', query: { 'h': 0, 'w': 0 } }, call: (request) => call_docker('POST', `/exec/${request.args.id}/resize`, { query: request.args.query }) }, inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/exec/${request.args.id}/json`) }, }; const container_methods = { list: { args: { query: { 'all': false, 'limit': false, 'size': false, 'filters': '' } }, call: (request) => call_docker('GET', '/containers/json', { query: request.args.query }) }, create: { args: { query: { 'name': '', 'platform': '' }, body: {} }, call: (request) => call_docker('POST', '/containers/create', { query: request.args.query, payload: request.args.body }) }, inspect: { args: { id: '', query: { 'size': false } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/json`, { query: request.args.query }) }, top: { args: { id: '', query: { 'ps_args': '' } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/top`, { query: request.args.query }) }, logs: { args: { id: '', query: {} }, call: (request) => call_docker('GET', `/containers/${request.args.id}/logs`, { query: request.args.query }) }, changes: { args: { id: '' }, call: (request) => call_docker('GET', `/containers/${request.args.id}/changes`) }, export: { args: { id: '' }, call: (request) => call_docker('GET', `/containers/${request.args.id}/export`) }, stats: { args: { id: '', query: { 'stream': false, 'one-shot': false } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/stats`, { query: request.args.query }) }, resize: { args: { id: '', query: { 'h': 0, 'w': 0 } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/resize`, { query: request.args.query }) }, start: { args: { id: '', query: { 'detachKeys': '' } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/start`, { query: request.args.query }) }, stop: { args: { id: '', query: { 'signal': '', 't': 0 } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/stop`, { query: request.args.query }) }, restart: { args: { id: '', query: { 'signal': '', 't': 0 } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/restart`, { query: request.args.query }) }, kill: { args: { id: '', query: { 'signal': '' } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/kill`, { query: request.args.query }) }, update: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/containers/${request.args.id}/update`, { payload: request.args.body }) }, rename: { args: { id: '', query: { 'name': '' } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/rename`, { query: request.args.query }) }, pause: { args: { id: '' }, call: (request) => call_docker('POST', `/containers/${request.args.id}/pause`) }, unpause: { args: { id: '' }, call: (request) => call_docker('POST', `/containers/${request.args.id}/unpause`) }, // attach // attach websocket // wait remove: { args: { id: '', query: { 'v': false, 'force': false, 'link': false } }, call: (request) => call_docker('DELETE', `/containers/${request.args.id}`, { query: request.args.query }) }, // archive info info_archive: { args: { id: '', query: { 'path': '' } }, call: (request) => call_docker('HEAD', `/containers/${request.args.id}/archive`, { query: request.args.query }) }, // archive get get_archive: { args: { id: '', query: { 'path': '' } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/archive`, { query: request.args.query }) }, // archive extract put_archive: { args: { id: '', query: { 'path': '', 'noOverwriteDirNonDir': '', 'copyUIDGID': '' }, body: '' }, call: (request) => call_docker('PUT', `/containers/${request.args.id}/archive`, { query: request.args.query, payload: request.args.body }) }, exec: { args: { id: '', opts: {} }, call: (request) => call_docker('POST', `/containers/${request.args.id}/exec`, { payload: request.args.opts }) }, prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/containers/prune', { query: request.args.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.args.query }) }, // build is long-running, and will likely cause time-out on the call. Function only here for reference. build: { args: { query: { '': '' }, headers: {} }, call: (request) => call_docker('POST', '/build', { query: request.args.query, headers: request.args.headers }) }, build_prune: { args: { query: { '': '' }, headers: {} }, call: (request) => call_docker('POST', '/build/prune', { query: request.args.query, headers: request.args.headers }) }, create: { args: { query: { '': '' }, headers: {} }, call: (request) => call_docker('POST', '/images/create', { query: request.args.query, headers: request.args.headers }) }, inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request.args.id}/json`) }, history: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request.args.id}/history`) }, push: { args: { name: '', query: { tag: '', platform: '' }, headers: {} }, call: (request) => call_docker('POST', `/images/${request.args.name}/push`, { query: request.args.query, headers: request.args.headers }) }, tag: { args: { id: '', query: { 'repo': '', 'tag': '' } }, call: (request) => call_docker('POST', `/images/${request.args.id}/tag`, { query: request.args.query }) }, remove: { args: { id: '', query: { 'force': false, 'noprune': false } }, call: (request) => call_docker('DELETE', `/images/${request.args.id}`, { query: request.args.query }) }, search: { args: { query: { 'term': '', 'limit': 0, 'filters': '' } }, call: (request) => call_docker('GET', '/images/search', { query: request.args.query }) }, prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/images/prune', { query: request.args.query }) }, // create/commit get: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request.args.id}/get`) }, // get == export several load: { args: { query: { 'quiet': false } }, call: (request) => call_docker('POST', '/images/load', { query: request.args.query }) }, }; const network_methods = { list: { args: { query: { 'filters': '' } }, call: (request) => call_docker('GET', '/networks', { query: request.args.query }) }, inspect: { args: { id: '', query: { 'verbose': false, 'scope': '' } }, call: (request) => call_docker('GET', `/networks/${request.args.id}`, { query: request.args.query }) }, remove: { args: { id: '' }, call: (request) => call_docker('DELETE', `/networks/${request.args.id}`) }, create: { args: { body: {} }, call: (request) => call_docker('POST', '/networks/create', { payload: request.args.body }) }, connect: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/networks/${request.args.id}/connect`, { payload: request.args.body }) }, disconnect: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/networks/${request.args.id}/disconnect`, { payload: request.args.body }) }, prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/networks/prune', { query: request.args.query }) }, }; const volume_methods = { list: { args: { query: { 'filters': '' } }, call: (request) => call_docker('GET', '/volumes', { query: request.args.query }) }, create: { args: { opts: {} }, call: (request) => call_docker('POST', '/volumes/create', { payload: request.args.opts }) }, inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/volumes/${request.args.id}`) }, update: { args: { id: '', query: { 'version': 0 }, spec: {} }, call: (request) => call_docker('PUT', `/volumes/${request.args.id}`, { query: request.args.query, payload: request.args.spec }) }, remove: { args: { id: '', query: { 'force': false } }, call: (request) => call_docker('DELETE', `/volumes/${request.args.id}`, { query: request.args.query }) }, prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/volumes/prune', { query: request.args.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, }; // CLI test mode - check if script is run directly (not loaded by rpcd) if (caller != 'rpcd') { // Usage: ./docker_rpc.uc // Example: ./docker_rpc.uc docker.network.list '{"query":{"filters":""}}' // Example: ./docker_rpc.uc docker.image.create '{"query":{"fromImage":"alpine","tag":"latest"}}' const scr_name = split(SCRIPT_NAME, '/')[-1] || 'docker_rpc.uc'; if (length(ARGV) < 1) { print(`Usage: ${scr_name} [json-args]\n`); print("Available methods:\n"); for (let obj in methods) { for (let name, info in methods[obj]) { let sig = name; if (info && info.args) { try { sig = `${sig} ${sprintf('\'%J\'', info.args)}`; } catch { sig = `${sig} `; } } print(` ${obj}.${sig}\n`); } } print("\nExamples:\n"); print(` ${scr_name} docker.version\n`); print(` ${scr_name} docker.network.list '{"query":{}}'\n`); print(` ${scr_name} docker.image.create '{"query":{"fromImage":"alpine","tag":"latest"}}'\n`); print(` ${scr_name} docker.container.list '{"query":{"all":true}}'\n`); exit(1); } const method_path = split(ARGV[0], '.'); if (length(method_path) < 1) { die(`Invalid method path: ${ARGV[0]}\n`); } // Build object path (e.g., "docker.network") const obj_parts = slice(method_path, 0, -1); const obj_name = join('.', obj_parts); const method_name = method_path[length(method_path) - 1]; if (!methods[obj_name]) { die(`Unknown object: ${obj_name}\n`); } if (!methods[obj_name][method_name]) { die(`Unknown method: ${obj_name}.${method_name}\n`); } // Parse args if provided let args = {}; if (length(ARGV) > 1) { try { args = json(ARGV[1]); } catch (e) { die(`Invalid JSON args: ${e}\n`); } } // Call the method const request = { args: args }; const result = methods[obj_name][method_name].call(request); // Pretty print result print(result, "\n"); exit(0); }; return methods;