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