Files
luci/applications/luci-app-dockerman/ucode/controller/docker.uc
Paul Donald baa0f16bb3 luci-app-dockerman: convert to JS
This is a complete rewrite of the original Lua
dockerman in ECMAScript and ucode. Now with most of the
Lua gone, we can rename LuCI to JUCI. JavaScript ucode
Configuration Interface :)

Docker manager basically saw no updates or bug fixes
due to the Lua update embargo and transition to ECMAScript
in the luci repo.

But now that the app is rewritten, updates should come
readily from the community. Expect a few bugs in this,
although it has seen lots of testing - it's also seen lots
of development in different directions. Networking
scenarios might require some additions and fixes to the
GUI.

Swarm functionality is not implemented in this client and
is left as an exercise to the community and those with time.
All functionality found in the original Lua version is
present in this one, except for container "upgrade".
Some minor differences are introduced to improve layout and
logic.

There is no "remote endpoint" any longer since sockets
are the main method of connecting to dockerd - and sockets
accept remote connections. Docker manager and dockerd
on the same host are a remote connection. Buuut, dockerd
removes listening on any IP without --tls* options after v27.

There is no encryption between docker manager and the
API endpoint, or the container consoles when using the
standard /var/run/docker.sock.

See: https://github.com/openwrt/luci/issues/7310

TODO: handle image update ("Upgrade") for a container

Signed-off-by: Paul Donald <newtwen+github@gmail.com>
2026-02-04 06:45:51 +01:00

394 lines
11 KiB
Ucode

// Docker HTTP streaming endpoint
// Copyright 2025 Paul Donald <newtwen+github@gmail.com>
// Licensed to the public under the Apache License 2.0.
// Built against the docker v1.47 API
'use strict';
import { stdout } from 'fs';
import * as ds from 'luci.docker_socket';
import * as socket from 'socket';
import { cursor } from 'uci';
const BUFF_HEAD = 6; // 8000\r\n
const BUFF_TAIL = 2; // \r\n
const BLOCKSIZE = BUFF_HEAD + 0x8000 + BUFF_TAIL; //sync with Docker chunk size, 32776
// const API_VER = 'v1.47';
const PROTOCOL = 'HTTP/1.1';
const CLIENT_VER = '1';
let DockerController = {
// Handle file upload for chunked transfer
handle_file_upload: function(sock) {
let total_bytes = 0;
http.setfilehandler(function(meta, chunk, eof) {
if (meta.file && meta.name === 'upload-archive') {
if (chunk && length(chunk) > 0) {
let hex_size = sprintf('%x', length(chunk));
sock.send(hex_size + '\r\n');
sock.send(chunk);
sock.send('\r\n');
total_bytes += length(chunk);
}
if (eof) {
sock.send('0\r\n\r\n');
}
}
});
return total_bytes;
},
// Reusable header builder
build_headers: function(headers) {
let hdrs = [];
if (headers) {
for (let key in headers) {
if (headers[key] != null && headers[key] != '') {
push(hdrs, `${key}: ${headers[key]}`);
}
}
}
return length(hdrs) ? join('\r\n', hdrs) : '';
},
// Parse the initial HTTP response, split into parts and header lines, and store as properties
initial_response_parser: function(response_buff) {
let parts = split(response_buff, /\r?\n\r?\n/, 2);
let header_lines = split(parts[0], /\r?\n/);
let status_line = header_lines[0];
let status_match = match(status_line, /HTTP\/\S+\s+(\d+)/);
let code = status_match ? int(status_match[1]) : 500;
this.response_parts = parts;
this.header_lines = header_lines;
this.status_line = status_line;
this.status_match = status_match;
this.code = code;
},
// Stream the rest of the response in chunks from the socket
stream_response_chunks: function(sock, blocksize) {
let chunk;
while ((chunk = sock.recv(blocksize))) {
if (chunk && length(chunk)) {
this.debug('Streaming chunk:', substr(chunk, 0, 10));
stdout.write(chunk);
}
}
},
// Send a 200 OK response with headers and body
/* Write CGI response directly to stdout bypassing http.uc
The minimum to trigger a valid response via CGI is typically
Status: \r\n
The Docker response contains the \r\n after its headers, and the browser can
handle the chunked encoding fine, so we just forward its output verbatim.
Docker emits a x-docker-container-path-stat header with some meta-data for
the path, which we forward. uhttpd seems to coalesce headers, and inject its
own, so we occasionally have two Connection: headers.
*/
send_initial_200_response: function(headers, body) {
stdout.write('Status: 200 OK\r\n');
if (headers && type(headers) == 'array') {
stdout.write(join('', headers));
}
if (body && index(body, 'HTTP/1.1 200 OK\r\n') === 0) {
stdout.write(substr(body, length('HTTP/1.1 200 OK\r\n')));
}
},
// Debug output if &debug=... is present
debug: function(...args) {
let dbg = http.formvalue('debug');
let tostr = function(x) { return `${x}`; };
if (dbg != null && dbg != '') {
http.prepare_content('application/json');
http.write_json({msg: join(' ', map(args, tostr)) + '\n' });
}
},
// Generic error response helper
error_response: function(code, msg, detail) {
http.status(code ?? 500, msg ?? 'Internal Error');
http.prepare_content('application/json');
let out = { error: msg ?? 'Internal Error' };
if (detail)
out.detail = detail;
http.write_json(out);
},
get_api_ver: function() {
let ctx = cursor();
let version = ctx.get('dockerd', 'globals', 'api_version') || '';
ctx.unload();
return version ? `/${version}` : '';
},
join_args_array: function(args) {
return (type(args) == "array") ? join('/', args) : args;
},
require_param: function(name) {
let val = http.formvalue(name);
if (!val || val == '') die({ code: 400, message: `Missing parameter: ${name}` });
return val;
},
// Reusable query string builder
build_query_str: function(query_params, skip_keys) {
let query_str = '';
if (query_params) {
let parts = [];
for (let key in query_params) {
if (skip_keys && (key in skip_keys))
continue;
let val = query_params[key];
if (val == null || val == '') continue;
if (type(val) === 'array') {
for (let v in val) {
push(parts, `${key}=${v}`);
}
} else {
push(parts, `${key}=${val}`);
}
}
if (length(parts))
query_str = '?' + join('&', parts);
}
return query_str;
},
get_archive: function(docker_path, id, docker_function, query_params, archive_name) {
this.debug('get_archive called', docker_path, id, docker_function, query_params, archive_name);
id = this.join_args_array(id);
let id_param = '';
if (id) id_param = `/${id}`;
const sock_dest = ds.get_socket_dest_compat();
const sock = socket.create(sock_dest.family, socket.SOCK_STREAM);
this.debug('Socket created:', !!sock);
if (!sock) {
this.debug('Socket creation failed');
this.error_response(500, 'Failed to create socket');
return;
}
if (!sock.connect(sock_dest)) {
this.debug('Socket connect failed');
sock.close();
this.error_response(503, 'Failed to connect to Docker daemon');
return;
}
let query_str = type(query_params) === 'object' ? this.build_query_str(query_params) : `?${query_params}`;
let url = `${docker_path}${id_param}${docker_function}${query_str}`;
let req = [
`GET ${this.get_api_ver()}${url} ${PROTOCOL}`,
`Host: openwrt-docker-ui`,
`User-Agent: luci-app-dockerman-rpc-ucode/${CLIENT_VER}`,
`Connection: close`,
``,
``
];
this.debug('Sending request:', req);
sock.send(join('\r\n', req));
let response_buff = sock.recv(BLOCKSIZE);
this.debug('Received response header block:', response_buff ? substr(response_buff, 0, 100) : 'null');
if (!response_buff || response_buff == '') {
this.debug('No response from Docker daemon');
sock.close();
this.error_response(500, 'No response from Docker daemon');
return;
}
this.initial_response_parser(response_buff);
if (this.code != 200) {
this.debug('Docker error status:', this.code, this.status_line);
sock.close();
this.error_response(this.code, 'Docker Error', this.status_line);
return;
}
let filename = length(id) >= 64 ? substr(id, 0, 12) : id;
if (!filename) filename = 'multi';
let include_headers = [`Content-Disposition: attachment; filename=\"${filename}_${archive_name}\"\r\n`];
this.send_initial_200_response(include_headers, response_buff);
this.stream_response_chunks(sock, BLOCKSIZE);
sock.close();
return;
},
docker_send: function(method, docker_path, docker_function, query_params, headers, haveFile) {
this.debug('docker_send called', method, docker_path, docker_function, query_params, headers, haveFile);
const sock_dest = ds.get_socket_dest_compat();
const sock = socket.create(sock_dest.family, socket.SOCK_STREAM);
this.debug('Socket created:', !!sock);
if (!sock) {
this.debug('Socket creation failed');
this.error_response(500, 'Failed to create socket');
return;
}
if (!sock.connect(sock_dest)) {
this.debug('Socket connect failed');
sock.close();
this.error_response(503, 'Failed to connect to Docker daemon');
return;
}
let skip_keys = {
token: true,
'X-Registry-Auth': true,
'upload-name': true,
'upload-archive': true,
'upload-path': true,
};
let remote = false;
if (query_params && type(query_params) === 'object' &&
query_params['remote'] != null && query_params['remote'] != '')
remote = true;
let query_str = type(query_params) === 'object' ? this.build_query_str(query_params, skip_keys) : `?${query_params}`;
let hdr_str = this.build_headers(headers);
let url = `${docker_path}${docker_function}${query_str}`;
let req = [
`${method} ${this.get_api_ver()}${url} ${PROTOCOL}`,
`Host: openwrt-docker-ui`,
`User-Agent: luci-app-docker-controller-ucode/${CLIENT_VER}`,
`Connection: close`,
];
if (hdr_str)
push(req, hdr_str);
if (haveFile) {
push(req, 'Content-Type: application/x-tar');
push(req, 'Transfer-Encoding: chunked');
}
push(req, '');
push(req, '');
this.debug('Sending request:', req);
sock.send(join('\r\n', req));
if (haveFile)
this.handle_file_upload(sock);
else
sock.send('\r\n\r\n');
let response_buff = sock.recv(BLOCKSIZE);
this.debug('Received response header block:', response_buff ? substr(response_buff, 0, 100) : 'null');
if (!response_buff || response_buff == '') {
this.debug('No response from Docker daemon');
sock.close();
this.error_response(500, 'No response from Docker daemon');
return;
}
this.initial_response_parser(response_buff);
if (this.code != 200) {
this.debug('Docker error status:', this.code, this.status_line);
sock.close();
this.error_response(this.code, 'Docker Error', this.status_line);
return;
}
this.send_initial_200_response('', response_buff);
this.stream_response_chunks(sock, BLOCKSIZE);
sock.close();
return;
},
// Handler methods
container_get_archive: function(id) {
this.require_param('path');
this.get_archive('/containers', id, '/archive', http.message.env.QUERY_STRING, 'file_archive.tar');
},
container_export: function(id) {
this.get_archive('/containers', id, '/export', null, 'container_export.tar');
},
containers_prune: function() {
this.docker_send('POST', '/containers', '/prune', http.message.env.QUERY_STRING, {}, false);
},
container_put_archive: function(id) {
this.require_param('path');
this.docker_send('PUT', '/containers', `/${id}/archive`, http.message.env.QUERY_STRING, {}, true);
},
docker_events: function() {
this.docker_send('GET', '', '/events', http.message.env.QUERY_STRING, {}, false);
},
image_build: function(...args) {
let remote = http.formvalue('remote');
this.docker_send('POST', '', '/build', http.message.env.QUERY_STRING, {}, !remote);
},
image_build_prune: function() {
this.docker_send('POST', '/build', '/prune', http.message.env.QUERY_STRING, {}, false);
},
image_create: function() {
let headers = {
'X-Registry-Auth': http.formvalue('X-Registry-Auth'),
};
this.docker_send('POST', '/images', '/create', http.message.env.QUERY_STRING, headers, false);
},
image_get: function(...args) {
this.get_archive('/images', args, '/get', http.message.env.QUERY_STRING, 'image_export.tar');
},
image_load: function() {
this.docker_send('POST', '/images', '/load', http.message.env.QUERY_STRING, {}, true);
},
images_prune: function() {
this.docker_send('POST', '/images', '/prune', http.message.env.QUERY_STRING, {}, false);
},
image_push: function(...args) {
let headers = {
'X-Registry-Auth': http.formvalue('X-Registry-Auth'),
};
this.docker_send('POST', `/images/${this.join_args_array(args)}`, '/push', http.message.env.QUERY_STRING, headers, false);
},
networks_prune: function() {
this.docker_send('POST', '/networks', '/prune', http.message.env.QUERY_STRING, {}, false);
},
volumes_prune: function() {
this.docker_send('POST', '/volumes', '/prune', http.message.env.QUERY_STRING, {}, false);
},
};
// Export all handlers with automatic error wrapping
let controller = DockerController;
let exports = {};
for (let k, v in controller) {
if (type(v) == 'function')
exports[k] = v;
}
return exports;