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