f77f113819b6727227a2269d60c7ae0c9326fd96
[project/unetd.git] / scripts / unet-cli
1 #!/usr/bin/env ucode
2
3 'use strict';
4
5 import { access, basename, dirname, mkstemp, open, writefile } from 'fs';
6
7 function assert(cond, message) {
8 if (!cond) {
9 warn(message, "\n");
10 exit(1);
11 }
12
13 return true;
14 }
15
16 let unet_tool = "unet-tool";
17 let script_dir = sourcepath(0, true);
18
19 if (basename(script_dir) == "scripts") {
20 unet_tool = `${dirname(script_dir)}/unet-tool`;
21 assert(access(unet_tool, "x"), "unet-tool missing");
22 }
23
24 let args = {};
25
26 const defaults = {
27 port: 51830,
28 pex_port: 51831,
29 keepalive: 10,
30 };
31
32 const usage_message = `
33 Usage: ${basename(sourcepath())} [<flags>] <file> <command> [<args>] [<option>=<value> ...]
34
35 Commands:
36 - create: Create a new network file
37 - set-config: Change network config parameters
38 - add-host <name>: Add a host
39 - add-ssh-host <name> <host>: Add a remote OpenWrt host via SSH
40 (<host> can contain SSH options as well)
41 - set-host <name>: Change host settings
42 - set-ssh-host <name> <host>: Update local and remote host settings
43 - add-service <name>: Add a service
44 - set-service <name>: Change service settings
45 - sign Sign network data
46
47 Flags:
48 -p: Print modified JSON instead of updating file
49
50 Options:
51 - config options (create, set-config):
52 port=<val> set tunnel port (default: ${defaults.port})
53 pex_port=<val> set peer-exchange port (default: ${defaults.pex_port})
54 keepalive=<val> set keepalive interval (seconds, 0: off, default: ${defaults.keepalive})
55 - host options (add-host, add-ssh-host, set-host):
56 key=<val> set host public key (required for add-host)
57 port=<val> set host tunnel port number
58 groups=[+|-]<val>[,<val>...] set/add/remove groups that the host is a member of
59 ipaddr=[+|-]<val>[,<val>...] set/add/remove host ip addresses
60 subnet=[+|-]<val>[,<val>...] set/add/remove host announced subnets
61 endpoint=<val> set host endpoint address
62 gateway=<name> set host gateway (using name of other host)
63 - ssh host options (add-ssh-host, set-ssh-host)
64 auth_key=<key> use <key> as public auth key on the remote host
65 priv_key=<key> use <key> as private host key on the remote host (default: generate a new key)
66 interface=<name> use <name> as interface in /etc/config/network on the remote host
67 domain=<name> use <name> as hosts file domain on the remote host (default: unet)
68 connect=<val>[,<val>...] set IP addresses that the host will contact for network updates
69 tunnels=<ifname>:<service>[,...] set active tunnel devices
70 - service options (add-service, set-service):
71 type=<val> set service type (required for add-service)
72 members=[+|-]<val>[,<val>...] set/add/remove service member hosts/groups
73 - vxlan service options (add-service, set-service):
74 id=<val> set VXLAN ID
75 port=<val> set VXLAN port
76 mtu=<val> set VXLAN device MTU
77 forward_ports=[+|-]<val>[,<val>...] set members allowed to receive broadcast/multicast/unknown-unicast
78 - sign options:
79 upload=<ip>[,<ip>...] upload signed file to hosts
80
81 `;
82
83 function usage() {
84 warn(usage_message);
85 return 1;
86 }
87
88 if (length(ARGV) < 2)
89 exit(usage());
90
91 let file = shift(ARGV);
92 let command = shift(ARGV);
93
94 const field_types = {
95 int: function(object, name, val) {
96 object[name] = int(val);
97 },
98 string: function(object, name, val) {
99 object[name] = val;
100 },
101 array: function(object, name, val) {
102 let op = substr(val, 0, 1);
103
104 if (op == "+" || op == "-") {
105 val = substr(val, 1);
106 object[name] ??= [];
107 } else {
108 op = "=";
109 object[name] = [];
110 }
111
112 let vals = split(val, ",");
113 for (val in vals) {
114 object[name] = filter(object[name], function(v) {
115 return v != val
116 });
117 if (op != "-")
118 push(object[name], val);
119 }
120
121 if (!length(object[name]))
122 delete object[name];
123 },
124 };
125
126 const service_field_types = {
127 vxlan: {
128 id: "int",
129 port: "int",
130 mtu: "int",
131 forward_ports: "array",
132 },
133 };
134
135 const ssh_script = `
136
137 set_list() {
138 local field="$1"
139 local val="$2"
140
141 first=1
142 for cur in $val; do
143 if [ -n "$first" ]; then
144 cmd=set
145 else
146 cmd=add_list
147 fi
148 uci $cmd "network.$INTERFACE.$field=$cur"
149 first=
150 done
151 }
152 set_interface_attrs() {
153 [ -n "$AUTH_KEY" ] && uci set "network.$INTERFACE.auth_key=$AUTH_KEY"
154 set_list connect "$CONNECT"
155 set_list tunnels "$TUNNELS"
156 uci set "network.$INTERFACE.domain=$DOMAIN"
157 }
158
159 check_interface() {
160 [ "$(uci -q get "network.$INTERFACE")" = "interface" -a "$(uci -q get "network.$INTERFACE.proto")" = "unet" ] && return 0
161 uci batch <<EOF
162 set network.$INTERFACE=interface
163 set network.$INTERFACE.proto=unet
164 set network.$INTERFACE.device=$INTERFACE
165 EOF
166 }
167
168 check_interface_key() {
169 key="$(uci -q get "network.$INTERFACE.key" | unet-tool -q -H -K -)"
170 [ -n "$key" ] || {
171 uci set "network.$INTERFACE.key=$(unet-tool -G)"
172 key="$(uci get "network.$INTERFACE.key" | unet-tool -H -K -)"
173 }
174 echo "key=$key"
175 }
176
177 check_interface
178 check_interface_key
179 set_interface_attrs
180 uci commit
181 reload_config
182 ifup $INTERFACE
183 `;
184
185 let print_only = false;
186
187 function fetch_args() {
188 for (let arg in ARGV) {
189 let vals = match(arg, /^(.[[:alnum:]_-]*)=(.*)$/);
190 assert(vals, `Invalid argument: ${arg}`);
191 args[vals[1]] = vals[2]
192 }
193 }
194
195 function set_field(typename, object, name, val) {
196 if (!field_types[typename]) {
197 warn(`Invalid type ${type}\n`);
198 return;
199 }
200
201 if (type(val) != "string")
202 return;
203
204 if (val == "") {
205 delete object[name];
206 return;
207 }
208
209 field_types[typename](object, name, val);
210 }
211
212 function set_fields(object, list) {
213 for (let f in list)
214 set_field(list[f], object, f, args[f]);
215 }
216
217 function set_host(host) {
218 set_fields(host, {
219 key: "string",
220 endpoint: "string",
221 gateway: "string",
222 port: "int",
223 ipaddr: "array",
224 subnet: "array",
225 groups: "array",
226 });
227 }
228
229 function set_service(service) {
230 set_fields(service, {
231 type: "string",
232 members: "array",
233 });
234
235 if (service_field_types[service.type])
236 set_fields(service.config, service_field_types[service.type]);
237 }
238
239 function sync_ssh_host(host) {
240 let interface = args.interface ?? "unet";
241 let connect = replace(args.connect ?? "", ",", " ");
242 let auth_key = args.auth_key;
243 let tunnels = replace(replace(args.tunnels ?? "", ",", " "), ":", "=");
244 let domain = args.domain ?? "unet";
245
246 if (!auth_key) {
247 let fh = mkstemp();
248 system(`${unet_tool} -q -P -K ${file}.key >&${fh.fileno()}`);
249 fh.seek();
250 auth_key = fh.read("line");
251 fh.close();
252 auth_key = replace(auth_key, "\n", "");
253 if (auth_key == "") {
254 warn("Could not read auth key\n");
255 exit(1);
256 }
257 }
258
259 let fh = mkstemp();
260 fh.write(`INTERFACE='${interface}'\n`);
261 fh.write(`CONNECT='${connect}'\n`);
262 fh.write(`AUTH_KEY='${auth_key}'\n`);
263 fh.write(`TUNNELS='${tunnels}'\n`);
264 fh.write(`DOMAIN='${domain}'\n`);
265 fh.write(ssh_script);
266 fh.flush();
267 fh.seek();
268
269 let fh2 = mkstemp();
270 system(`ssh ${host} sh <&${fh.fileno()} >&${fh2.fileno()}`);
271 fh.close();
272
273 let data = {}, line;
274
275 fh2.seek();
276 while (line = fh2.read("line")) {
277 let vals = match(line, /^(.[[:alnum:]_-]*)=(.*)\n$/);
278 assert(vals, `Invalid argument: ${line}`);
279 data[vals[1]] = vals[2]
280 }
281 fh2.close();
282
283 assert(data.key, "Could not read host key from SSH host");
284
285 args.key = data.key;
286 }
287
288 while (substr(ARGV[0], 0, 1) == "-") {
289 let opt = shift(ARGV);
290 if (opt == "--")
291 break;
292 else if (opt == "-p")
293 print_only = true;
294 else
295 exit(usage());
296 }
297
298 let hostname, ssh_host, servicename;
299
300 if (command in [ "add-host", "set-host", "add-ssh-host", "set-ssh-host" ]) {
301 hostname = shift(ARGV);
302 assert(hostname, "Missing host name argument");
303 }
304
305 if (command in [ "add-ssh-host", "set-ssh-host" ]) {
306 ssh_host = shift(ARGV);
307 assert(ssh_host, "Missing SSH host/user argument");
308 }
309
310 if (command in [ "add-service", "set-service" ]) {
311 servicename = shift(ARGV);
312 assert(servicename, "Missing service name argument");
313 }
314
315 fetch_args();
316
317 if (command in [ "add-ssh-host", "set-ssh-host" ]) {
318 sync_ssh_host(ssh_host);
319 command = replace(command, "ssh-", "");
320 }
321
322 let net_data;
323
324 if (command == "create") {
325 net_data = {
326 config: {},
327 hosts: {},
328 services: {}
329 };
330 } else {
331 let fh = open(file);
332 assert(fh, `Could not open input file ${file}`);
333
334 try {
335 net_data = json(fh);
336 } catch(e) {
337 assert(false, `Could not parse input file ${file}`);
338 }
339 }
340
341 if (command == "create") {
342 for (let key, val in defaults)
343 args[key] ??= `${val}`;
344 if (!access(`${file}.key`))
345 system(`${unet_tool} -G > ${file}.key`);
346 }
347
348 if (command == "sign") {
349 let ret = system(`${unet_tool} -S -K ${file}.key -o ${file}.bin ${file}`);
350 if (ret != 0)
351 exit(ret);
352
353 if (args.upload) {
354 for (let host in split(args.upload, ",")) {
355 warn(`Uploading ${file}.bin to ${host}\n`);
356 ret = system(`${unet_tool} -U ${host} -K ${file}.key ${file}.bin`);
357 if (ret)
358 warn("Upload failed\n");
359 }
360 }
361 exit(0);
362 }
363
364 switch (command) {
365 case 'create':
366 case 'set-config':
367 set_fields(net_data.config, {
368 port: "int",
369 keepalive: "int",
370 });
371 set_field("int", net_data.config, "peer-exchange-port", args.pex_port);
372 break;
373
374 case 'add-host':
375 net_data.hosts[hostname] = {};
376 assert(args.key, "Missing host key");
377 set_host(net_data.hosts[hostname]);
378 break;
379
380 case 'set-host':
381 assert(net_data.hosts[hostname], `Host '${hostname}' does not exist`);
382 set_host(net_data.hosts[hostname]);
383 break;
384
385 case 'add-service':
386 net_data.services[servicename] = {
387 config: {},
388 members: [],
389 };
390 assert(args.type, "Missing service type");
391 set_service(net_data.services[servicename]);
392 break;
393
394 case 'set-service':
395 assert(net_data.services[servicename], `Service '${servicename}' does not exist`);
396 set_service(net_data.services[servicename]);
397 break;
398
399 default:
400 assert(false, "Unknown command");
401 }
402
403 const net_data_json = sprintf("%.J\n", net_data);
404
405 if (print_only)
406 print(net_data_json);
407 else
408 writefile(file, net_data_json);