mirror of
https://github.com/openwrt/luci.git
synced 2026-04-15 10:51:51 +00:00
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>
394 lines
11 KiB
Ucode
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;
|