#!/usr/bin/lua local uci = require('simple-uci').cursor() local ip = require 'luci.ip' -- luci-lib-ip local fetch = require 'luci.httpclient' local json = require 'luci.jsonc' local ecdsa = require 'gluon.ecdsa' local site = require 'gluon.site' local pretty_hostname = require 'pretty_hostname' local hostname = pretty_hostname.get(uci) local manapi = site.manman.api() local mankey = site.manman.key() -- CLI lib from olsrd-cli (TODO: move to it's own lib package) -- DO NOT EDIT HERE, JUST COPY FROM THERE function printf(...) print(string.format(...)) end -- Print contents of `tbl`, with indentation. -- `indent` sets the initial level of indentation. -- src https://gist.github.com/xytis/5361405 function tprint (tbl, indent) if not indent then indent = 0 end for k, v in pairs(tbl) do formatting = string.rep(' ', indent) .. k .. ': ' if type(v) == 'table' then print(formatting) tprint(v, indent + 1) else print(formatting .. tostring(v)) end end end -- src https://stackoverflow.com/a/24823383/3990041 function table.slice(tbl, first, last, step) local sliced = {} for i = first or 1, last or #tbl, step or 1 do sliced[#sliced+1] = tbl[i] end return sliced end -- CLI lib function exec_cmd(args, sub) if sub[args[1]] == nil then return cmd_err('does not exist') else local cmd = sub[args[1]] if cmd[3] ~= nil and #args > 1 then exec_cmd(table.slice(args, 2), cmd[3]) else cmd[1](unpack(table.slice(args, 2))) end end end function list_cmd(level, sub) for key, cmd in pairs(sub) do printf('%s%s: %s', string.rep(' ', level), key, cmd[2]) if cmd[3] ~= nil then list_cmd(level + 1, cmd[3]) end end end function show_help() printf('Usage: %s ', arg[0]) list_cmd(1, sub) end function cmd_err(msg, no_show_help) -- since argv0 is at... well... 0... even though this is lua... --- ...slice just returns arg without argv0 as the for starts at 1 printf('Error: Command "%s" %s', table.concat(table.slice(arg), ' '), msg) if not no_show_help then printf('') show_help() end os.exit(2) end function dummy() cmd_err('requires a subcommand') end -- /// -- NOTE: these will have mesh_ appended for static-ip local mappings = { wifi = 'radio0', tunnel = 'vpn', eth = 'other', lan = 'other', wan = 'uplink', single = 'uplink', } -- https://stackoverflow.com/a/1647577/3990041 function string:split(pat) pat = pat or '%s+' local st, g = 1, self:gmatch("()("..pat..")") local function getter(segs, seps, sep, cap1, ...) st = sep and seps + #sep return self:sub(segs, (seps or 0) - 1), cap1 or sep, ... end return function() if st then return getter(st, g()) end end end -- https://gist.github.com/Uradamus/10323382 local function shuffle(tbl) for i = #tbl, 2, -1 do local j = math.random(i) tbl[i], tbl[j] = tbl[j], tbl[i] end return tbl end local function fetch_signed_json(remote, url) print('GET ' .. url) local code, res, result = fetch.request_raw(remote .. url) if code < 1 then print('E: failed to fetch') return 1 end if code == 404 then print('E: location does not exist') return 2 end if code ~= 200 then print('E: got status code ' .. code) return 1 end -- cloudflare's reverse proxies send http chunked responses with chunk sizes -- for whatever reasons the chunk size gets smashed into the result -- this is a hack to fish it out, it is irrelevant on unaffected reverse proxies local j_start = result:find('{') local j_end = (result:reverse()):find("}") result = string.sub(result, j_start, (1 - j_end) > 0 and (1 - j_end) or nil) local sig = res.headers['x-ecdsa'] local ts = res.headers['x-ecdsa-ts'] if not sig or not ts then print('E: provided response is not signed') return 1 end local data = ts .. '@' .. result if not ecdsa.verify(data, sig, mankey) then print('E: signature invalid or not signed with expected key') return 1 end local obj = json.parse(result) if obj == nil then print('E: failed to parse json data') return 1 end return false, obj end local function do_manman_sync() if not uci:get('gluon-manman-sync', 'sync', 'location_id') and not uci:get('gluon-manman-sync', 'sync', 'location') then print('E: manman location missing') return 2 end -- check manman reachability, abort if not reachable local working_remote for _, remote in ipairs(shuffle(manapi)) do if not working_remote then -- don't try other remotes if we got one that works print('Trying remote ' .. remote) local success, a, b, c = pcall(function() return fetch.request_raw(remote .. '/') end) if not success then print('E: couldnt reach manman: ' .. a) else if a ~= 200 then print('E: couldnt reach manman - unexpected fetch result', a, b, c) else working_remote = remote end end end end if not working_remote then print('E: couldnt reach any manapi server, giving up') return 1 end -- try to fetch data print('Fetching manman data...') local location_id = uci:get('gluon-manman-sync', 'sync', 'location_id') local location = uci:get('gluon-manman-sync', 'sync', 'location') if not location_id and location then local err, resp = fetch_signed_json(working_remote, '/find_location_by_name/' .. location) if err then return err end if not resp.id then printf('E: location %s is invalid (404)', location) return 2 end uci:set('gluon-manman-sync', 'sync', 'location_id', resp.id) location_id = resp.id end local err, location = fetch_signed_json(working_remote, '/location/show/' .. location_id) if err then return err end print('Syncing with location ' .. location.location.name) uci:set('gluon-manman-sync', 'sync', 'location', location.location.name) if uci:get('gluon-manman-sync', 'sync', 'last_data') and json.stringify(location) == uci:get('gluon-manman-sync', 'sync', 'last_data') then print('Nothing changed, skipping sync') return 0 end local local_router_id for id, _ in string.split(hostname, '-') do if id then local_router_id = id end end local local_node_name = uci:get('gluon-manman-sync', 'sync', 'node') or local_router_id local local_node = uci:get('gluon-manman-sync', 'sync', 'node_id') or local_router_id local node local should_hostname if #location.nodes > 1 then for _, potential_node in ipairs(location.nodes) do if (local_node ~= nil and tostring(potential_node.id) == local_node) or (local_node_name ~= nil and potential_node.name == local_node_name) then node = potential_node should_hostname = location.location.name .. '-' .. node.name end end else node = location.nodes[1] should_hostname = location.location.name end -- save node data to update name and persist ID uci:set('gluon-manman-sync', 'sync', 'node_id', node.id) uci:set('gluon-manman-sync', 'sync', 'node', node.name) if node == nil then print('E: unable to find matching node (selector "' .. node .. '")') return 2 end print('Syncing data for node ' .. node.name) if hostname ~= should_hostname then print('Renaming node to ' .. should_hostname) pretty_hostname.set(uci, should_hostname) end local owner = uci:get_first('gluon-node-info', 'owner') uci:set('gluon-node-info', owner, 'contact', location.administrator.email) local _location = uci:get_first('gluon-node-info', 'location') uci:set('gluon-node-info', _location, 'share_location', '1') uci:set('gluon-node-info', _location, 'latitude', location.location.lat) uci:set('gluon-node-info', _location, 'longitude', location.location.long) -- check if anything changed since last time -- if yes, apply changes and do gluon-reload local has_changes = false -- Use this when changing something that needs a reload and/or rollback (not the hostname) local function set(a, b, c, d, isbool) local curval if isbool then curval = uci:get_bool(a, b, c) else curval = uci:get(a, b, c) end if curval ~= d then uci:set(a, b, c, d) print(' Value', a, b, c, 'changed to', d, 'was', curval) has_changes = true else print(' Value', a, b, c, 'unchanged', d) end end local has_tunnel = false for _, net in ipairs(node.interfaces) do local net_name = net.name if net_name == 'tunnel' or net_name == 'vpn' or net_name == 'mesh_vpn' then has_tunnel = true end local net_mapped = mappings[net_name] or net_name if not string.find(net_mapped, '_') then net_mapped = 'mesh_' .. net_mapped end local cidr = ip.new(net.ip, net.netmask):string() print('Syncing ' .. net_name .. ' as ' .. net_mapped .. ' to ' .. cidr) set('gluon-static-ip', net_mapped, 'ip4', cidr) end print('Syncing mesh vpn: ' .. (has_tunnel and 'on' or 'off')) set('gluon', 'mesh_vpn', 'enabled', has_tunnel, true) uci:set('gluon-manman-sync', 'sync', 'last_data', json.stringify(location)) uci:save('system') uci:save('gluon') uci:save('gluon-manman-sync') uci:save('gluon-static-ip') uci:save('gluon-node-info') os.execute('exec uci commit') if has_changes then print('Applying changes...') os.execute('exec gluon-reconfigure') os.execute('exec gluon-reload') else print('No settings changes, no reason to reload') end end local function maybe_sync() if uci:get_bool('gluon-manman-sync', 'sync', 'enabled') then do_manman_sync() else print('manman-sync not enabled, skipping') end end local function enable_sync(location, node) if not uci:get('gluon-manman-sync', 'sync', 'location') and not uci:get('gluon-manman-sync', 'sync', 'location_id') and not location then cmd_err('requires at least a location (No location was found in config)', true) end if location then printf('Config location %s', location) uci:set('gluon-manman-sync', 'sync', 'location', location) -- clear ID, gets fetched from manman-sync uci:set('gluon-manman-sync', 'sync', 'location_id', nil) end if node then printf('Config node %s', node) uci:set('gluon-manman-sync', 'sync', 'node', node) -- clear ID, gets fetched from manman-sync uci:set('gluon-manman-sync', 'sync', 'node_id', nil) end if not uci:get_bool('gluon-manman-sync', 'sync', 'enabled') then uci:set('gluon-manman-sync', 'sync', 'enabled', true) print('Enabled sync.') print('Trigger with manman-sync sync') else print('Sync already enabled.') end uci:save('gluon-manman-sync') end local function disable_sync() if uci:get_bool('gluon-manman-sync', 'sync', 'enabled') then uci:set('gluon-manman-sync', 'sync', 'enabled', false) print('Disabled sync.') uci:save('gluon-manman-sync') end end local function show_info() tprint({ enabled = uci:get_bool('gluon-manman-sync', 'sync', 'enabled'), location = string.format('%s (id %s)', uci:get('gluon-manman-sync', 'sync', 'location') or '[none]', uci:get('gluon-manman-sync', 'sync', 'location_id') or '[will autodetect]' ), node = string.format('%s (id %s)', uci:get('gluon-manman-sync', 'sync', 'node') or '[none - use only available or error]', uci:get('gluon-manman-sync', 'sync', 'node_id') or '[will autodetect]' ), }) end sub = { info = { show_info, 'Show manman-sync configuration' }, help = { show_help, 'Show help' }, sync = { maybe_sync, 'Trigger a manman-sync' }, force_sync = { do_manman_sync, 'Trigger a manman-sync, despite being disabled' }, enable = { enable_sync, 'Enable sync. [] []' }, disable = { disable_sync, 'Disable sync.' } } exec_cmd(table.slice(arg), sub)