You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

vault.lua 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  1. --[[
  2. Copyright (c) 2019, Vsevolod Stakhov <vsevolod@highsecure.ru>
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. ]]--
  13. local rspamd_logger = require "rspamd_logger"
  14. local ansicolors = require "ansicolors"
  15. local ucl = require "ucl"
  16. local argparse = require "argparse"
  17. local fun = require "fun"
  18. local rspamd_http = require "rspamd_http"
  19. local cr = require "rspamd_cryptobox"
  20. local parser = argparse()
  21. :name "rspamadm vault"
  22. :description "Perform Hashicorp Vault management"
  23. :help_description_margin(32)
  24. :command_target("command")
  25. :require_command(true)
  26. parser:flag "-s --silent"
  27. :description "Do not output extra information"
  28. parser:option "-a --addr"
  29. :description "Vault address (if not defined in VAULT_ADDR env)"
  30. parser:option "-t --token"
  31. :description "Vault token (not recommended, better define VAULT_TOKEN env)"
  32. parser:option "-p --path"
  33. :description "Path to work with in the vault"
  34. :default "dkim"
  35. parser:option "-o --output"
  36. :description "Output format ('ucl', 'json', 'json-compact', 'yaml')"
  37. :argname("<type>")
  38. :convert {
  39. ucl = "ucl",
  40. json = "json",
  41. ['json-compact'] = "json-compact",
  42. yaml = "yaml",
  43. }
  44. :default "ucl"
  45. parser:command "list ls l"
  46. :description "List elements in the vault"
  47. local show = parser:command "show get"
  48. :description "Extract element from the vault"
  49. show:argument "domain"
  50. :description "Domain to create key for"
  51. :args "+"
  52. local delete = parser:command "delete del rm remove"
  53. :description "Delete element from the vault"
  54. delete:argument "domain"
  55. :description "Domain to create delete key(s) for"
  56. :args "+"
  57. local newkey = parser:command "newkey new create"
  58. :description "Add new key to the vault"
  59. newkey:argument "domain"
  60. :description "Domain to create key for"
  61. :args "+"
  62. newkey:option "-s --selector"
  63. :description "Selector to use"
  64. :count "?"
  65. newkey:option "-A --algorithm"
  66. :argname("<type>")
  67. :convert {
  68. rsa = "rsa",
  69. ed25519 = "ed25519",
  70. eddsa = "ed25519",
  71. }
  72. :default "rsa"
  73. newkey:option "-b --bits"
  74. :argname("<nbits>")
  75. :convert(tonumber)
  76. :default "1024"
  77. newkey:option "-x --expire"
  78. :argname("<days>")
  79. :convert(tonumber)
  80. newkey:flag "-r --rewrite"
  81. local roll = parser:command "roll rollover"
  82. :description "Perform keys rollover"
  83. roll:argument "domain"
  84. :description "Domain to roll key(s) for"
  85. :args "+"
  86. roll:option "-T --ttl"
  87. :description "Validity period for old keys (days)"
  88. :convert(tonumber)
  89. :default "1"
  90. roll:flag "-r --remove-expired"
  91. :description "Remove expired keys"
  92. roll:option "-x --expire"
  93. :argname("<days>")
  94. :convert(tonumber)
  95. local function printf(fmt, ...)
  96. if fmt then
  97. io.write(rspamd_logger.slog(fmt, ...))
  98. end
  99. io.write('\n')
  100. end
  101. local function maybe_printf(opts, fmt, ...)
  102. if not opts.silent then
  103. printf(fmt, ...)
  104. end
  105. end
  106. local function highlight(str, color)
  107. return ansicolors[color or 'white'] .. str .. ansicolors.reset
  108. end
  109. local function vault_url(opts, path)
  110. if path then
  111. return string.format('%s/v1/%s/%s', opts.addr, opts.path, path)
  112. end
  113. return string.format('%s/v1/%s', opts.addr, opts.path)
  114. end
  115. local function is_http_error(err, data)
  116. return err or (math.floor(data.code / 100) ~= 2)
  117. end
  118. local function parse_vault_reply(data)
  119. local p = ucl.parser()
  120. local res,parser_err = p:parse_string(data)
  121. if not res then
  122. return nil,parser_err
  123. else
  124. return p:get_object(),nil
  125. end
  126. end
  127. local function maybe_print_vault_data(opts, data, func)
  128. if data then
  129. local res,parser_err = parse_vault_reply(data)
  130. if not res then
  131. printf('vault reply for cannot be parsed: %s', parser_err)
  132. else
  133. if func then
  134. printf(ucl.to_format(func(res), opts.output))
  135. else
  136. printf(ucl.to_format(res, opts.output))
  137. end
  138. end
  139. else
  140. printf('no data received')
  141. end
  142. end
  143. local function print_dkim_txt_record(b64, selector, alg)
  144. local labels = {}
  145. local prefix = string.format("v=DKIM1; k=%s; p=", alg)
  146. b64 = prefix .. b64
  147. if #b64 < 255 then
  148. labels = {'"' .. b64 .. '"'}
  149. else
  150. for sl=1,#b64,256 do
  151. table.insert(labels, '"' .. b64:sub(sl, sl + 255) .. '"')
  152. end
  153. end
  154. printf("%s._domainkey IN TXT ( %s )", selector,
  155. table.concat(labels, "\n\t"))
  156. end
  157. local function show_handler(opts, domain)
  158. local uri = vault_url(opts, domain)
  159. local err,data = rspamd_http.request{
  160. config = rspamd_config,
  161. ev_base = rspamadm_ev_base,
  162. session = rspamadm_session,
  163. resolver = rspamadm_dns_resolver,
  164. url = uri,
  165. headers = {
  166. ['X-Vault-Token'] = opts.token
  167. }
  168. }
  169. if is_http_error(err, data) then
  170. printf('cannot get request to the vault (%s), HTTP error code %s', uri, data.code)
  171. maybe_print_vault_data(opts, err)
  172. os.exit(1)
  173. else
  174. maybe_print_vault_data(opts, data.content, function(obj)
  175. return obj.data.selectors
  176. end)
  177. end
  178. end
  179. local function delete_handler(opts, domain)
  180. local uri = vault_url(opts, domain)
  181. local err,data = rspamd_http.request{
  182. config = rspamd_config,
  183. ev_base = rspamadm_ev_base,
  184. session = rspamadm_session,
  185. resolver = rspamadm_dns_resolver,
  186. url = uri,
  187. method = 'delete',
  188. headers = {
  189. ['X-Vault-Token'] = opts.token
  190. }
  191. }
  192. if is_http_error(err, data) then
  193. printf('cannot get request to the vault (%s), HTTP error code %s', uri, data.code)
  194. maybe_print_vault_data(opts, err)
  195. os.exit(1)
  196. else
  197. printf('deleted key(s) for %s', domain)
  198. end
  199. end
  200. local function list_handler(opts)
  201. local uri = vault_url(opts)
  202. local err,data = rspamd_http.request{
  203. config = rspamd_config,
  204. ev_base = rspamadm_ev_base,
  205. session = rspamadm_session,
  206. resolver = rspamadm_dns_resolver,
  207. url = uri .. '?list=true',
  208. headers = {
  209. ['X-Vault-Token'] = opts.token
  210. }
  211. }
  212. if is_http_error(err, data) then
  213. printf('cannot get request to the vault (%s), HTTP error code %s', uri, data.code)
  214. maybe_print_vault_data(opts, err)
  215. os.exit(1)
  216. else
  217. maybe_print_vault_data(opts, data.content, function(obj)
  218. return obj.data.keys
  219. end)
  220. end
  221. end
  222. -- Returns pair privkey+pubkey
  223. local function genkey(opts)
  224. return cr.gen_dkim_keypair(opts.algorithm, opts.bits)
  225. end
  226. local function create_and_push_key(opts, domain, existing)
  227. local uri = vault_url(opts, domain)
  228. local sk,pk = genkey(opts)
  229. local res = {
  230. selectors = {
  231. [1] = {
  232. selector = opts.selector,
  233. domain = domain,
  234. key = tostring(sk),
  235. pubkey = tostring(pk),
  236. alg = opts.algorithm,
  237. bits = opts.bits or 0,
  238. valid_start = os.time(),
  239. }
  240. }
  241. }
  242. for _,sel in ipairs(existing) do
  243. res.selectors[#res.selectors + 1] = sel
  244. end
  245. if opts.expire then
  246. res.selectors[1].valid_end = os.time() + opts.expire * 3600 * 24
  247. end
  248. local err,data = rspamd_http.request{
  249. config = rspamd_config,
  250. ev_base = rspamadm_ev_base,
  251. session = rspamadm_session,
  252. resolver = rspamadm_dns_resolver,
  253. url = uri,
  254. method = 'put',
  255. headers = {
  256. ['Content-Type'] = 'application/json',
  257. ['X-Vault-Token'] = opts.token
  258. },
  259. body = {
  260. ucl.to_format(res, 'json-compact')
  261. },
  262. }
  263. if is_http_error(err, data) then
  264. printf('cannot get request to the vault (%s), HTTP error code %s', uri, data.code)
  265. maybe_print_vault_data(opts, data.content)
  266. os.exit(1)
  267. else
  268. maybe_printf(opts,'stored key for: %s, selector: %s', domain, opts.selector)
  269. maybe_printf(opts, 'please place the corresponding public key as following:')
  270. if opts.silent then
  271. printf('%s', pk)
  272. else
  273. print_dkim_txt_record(tostring(pk), opts.selector, opts.algorithm)
  274. end
  275. end
  276. end
  277. local function newkey_handler(opts, domain)
  278. local uri = vault_url(opts, domain)
  279. if not opts.selector then
  280. opts.selector = string.format('%s-%s', opts.algorithm,
  281. os.date("!%Y%m%d"))
  282. end
  283. local err,data = rspamd_http.request{
  284. config = rspamd_config,
  285. ev_base = rspamadm_ev_base,
  286. session = rspamadm_session,
  287. resolver = rspamadm_dns_resolver,
  288. url = uri,
  289. method = 'get',
  290. headers = {
  291. ['X-Vault-Token'] = opts.token
  292. }
  293. }
  294. if is_http_error(err, data) or not data.content then
  295. create_and_push_key(opts, domain,{})
  296. else
  297. -- Key exists
  298. local rep = parse_vault_reply(data.content)
  299. if not rep or not rep.data then
  300. printf('cannot parse reply for %s: %s', uri, data.content)
  301. os.exit(1)
  302. end
  303. local elts = rep.data.selectors
  304. if not elts then
  305. create_and_push_key(opts, domain,{})
  306. os.exit(0)
  307. end
  308. for _,sel in ipairs(elts) do
  309. if sel.alg == opts.algorithm then
  310. printf('key with the specific algorithm %s is already presented at %s selector for %s domain',
  311. opts.algorithm, sel.selector, domain)
  312. os.exit(1)
  313. else
  314. create_and_push_key(opts, domain, elts)
  315. end
  316. end
  317. end
  318. end
  319. local function roll_handler(opts, domain)
  320. local uri = vault_url(opts, domain)
  321. local res = {
  322. selectors = {}
  323. }
  324. local err,data = rspamd_http.request{
  325. config = rspamd_config,
  326. ev_base = rspamadm_ev_base,
  327. session = rspamadm_session,
  328. resolver = rspamadm_dns_resolver,
  329. url = uri,
  330. method = 'get',
  331. headers = {
  332. ['X-Vault-Token'] = opts.token
  333. }
  334. }
  335. if is_http_error(err, data) or not data.content then
  336. printf("No keys to roll for domain %s", domain)
  337. os.exit(1)
  338. else
  339. local rep = parse_vault_reply(data.content)
  340. if not rep or not rep.data then
  341. printf('cannot parse reply for %s: %s', uri, data.content)
  342. os.exit(1)
  343. end
  344. local elts = rep.data.selectors
  345. if not elts then
  346. printf("No keys to roll for domain %s", domain)
  347. os.exit(1)
  348. end
  349. local nkeys = {} -- indexed by algorithm
  350. local function insert_key(sel, add_expire)
  351. if not nkeys[sel.alg] then
  352. nkeys[sel.alg] = {}
  353. end
  354. if add_expire then
  355. sel.valid_end = os.time() + opts.ttl * 3600 * 24
  356. end
  357. table.insert(nkeys[sel.alg], sel)
  358. end
  359. for _,sel in ipairs(elts) do
  360. if sel.valid_end and sel.valid_end < os.time() then
  361. if not opts.remove_expired then
  362. insert_key(sel, false)
  363. else
  364. maybe_printf(opts, 'removed expired key for %s (selector %s, expire "%s"',
  365. domain, sel.selector, os.date('%c', sel.valid_end))
  366. end
  367. else
  368. insert_key(sel, true)
  369. end
  370. end
  371. -- Now we need to ensure that all but one selectors have either expired or just a single key
  372. for alg,keys in pairs(nkeys) do
  373. table.sort(keys, function(k1, k2)
  374. if k1.valid_end and k2.valid_end then
  375. return k1.valid_end > k2.valid_end
  376. elseif k1.valid_end then
  377. return true
  378. elseif k2.valid_end then
  379. return false
  380. end
  381. return false
  382. end)
  383. -- Exclude the key with the highest expiration date and examine the rest
  384. if not (#keys == 1 or fun.all(function(k)
  385. return k.valid_end and k.valid_end < os.time()
  386. end, fun.tail(keys))) then
  387. printf('bad keys list for %s and %s algorithm', domain, alg)
  388. fun.each(function(k)
  389. if not k.valid_end then
  390. printf('selector %s, algorithm %s has a key with no expire',
  391. k.selector, k.alg)
  392. elseif k.valid_end >= os.time() then
  393. printf('selector %s, algorithm %s has a key that not yet expired: %s',
  394. k.selector, k.alg, os.date('%c', k.valid_end))
  395. end
  396. end, fun.tail(keys))
  397. os.exit(1)
  398. end
  399. -- Do not create new keys, if we only want to remove expired keys
  400. if not opts.remove_expired then
  401. -- OK to process
  402. -- Insert keys for each algorithm in pairs <old_key(s)>, <new_key>
  403. local sk,pk = genkey({algorithm = alg, bits = keys[1].bits})
  404. local selector = string.format('%s-%s', alg,
  405. os.date("!%Y%m%d"))
  406. if selector == keys[1].selector then
  407. selector = selector .. '-1'
  408. end
  409. local nelt = {
  410. selector = selector,
  411. domain = domain,
  412. key = tostring(sk),
  413. pubkey = tostring(pk),
  414. alg = alg,
  415. bits = keys[1].bits,
  416. valid_start = os.time(),
  417. }
  418. if opts.expire then
  419. nelt.valid_end = os.time() + opts.expire * 3600 * 24
  420. end
  421. table.insert(res.selectors, nelt)
  422. end
  423. for _,k in ipairs(keys) do
  424. table.insert(res.selectors, k)
  425. end
  426. end
  427. end
  428. -- We can now store res in the vault
  429. err,data = rspamd_http.request{
  430. config = rspamd_config,
  431. ev_base = rspamadm_ev_base,
  432. session = rspamadm_session,
  433. resolver = rspamadm_dns_resolver,
  434. url = uri,
  435. method = 'put',
  436. headers = {
  437. ['Content-Type'] = 'application/json',
  438. ['X-Vault-Token'] = opts.token
  439. },
  440. body = {
  441. ucl.to_format(res, 'json-compact')
  442. },
  443. }
  444. if is_http_error(err, data) then
  445. printf('cannot put request to the vault (%s), HTTP error code %s', uri, data.code)
  446. maybe_print_vault_data(opts, data.content)
  447. os.exit(1)
  448. else
  449. for _,key in ipairs(res.selectors) do
  450. if not key.valid_end or key.valid_end > os.time() + opts.ttl * 3600 * 24 then
  451. maybe_printf(opts,'rolled key for: %s, new selector: %s', domain, key.selector)
  452. maybe_printf(opts, 'please place the corresponding public key as following:')
  453. if opts.silent then
  454. printf('%s', key.pubkey)
  455. else
  456. print_dkim_txt_record(key.pubkey, key.selector, key.alg)
  457. end
  458. end
  459. end
  460. maybe_printf(opts, 'your old keys will be valid until %s',
  461. os.date('%c', os.time() + opts.ttl * 3600 * 24))
  462. end
  463. end
  464. local function handler(args)
  465. local opts = parser:parse(args)
  466. if not opts.addr then
  467. opts.addr = os.getenv('VAULT_ADDR')
  468. end
  469. if not opts.token then
  470. opts.token = os.getenv('VAULT_TOKEN')
  471. else
  472. maybe_printf(opts, 'defining token via command line is insecure, define it via environment variable %s',
  473. highlight('VAULT_TOKEN', 'red'))
  474. end
  475. if not opts.token or not opts.addr then
  476. printf('no token or/and vault addr has been specified, exiting')
  477. os.exit(1)
  478. end
  479. local command = opts.command
  480. if command == 'list' then
  481. list_handler(opts)
  482. elseif command == 'show' then
  483. fun.each(function(d) show_handler(opts, d) end, opts.domain)
  484. elseif command == 'newkey' then
  485. fun.each(function(d) newkey_handler(opts, d) end, opts.domain)
  486. elseif command == 'roll' then
  487. fun.each(function(d) roll_handler(opts, d) end, opts.domain)
  488. elseif command == 'delete' then
  489. fun.each(function(d) delete_handler(opts, d) end, opts.domain)
  490. else
  491. parser:error(string.format('command %s is not implemented', command))
  492. end
  493. end
  494. return {
  495. handler = handler,
  496. description = parser._description,
  497. name = 'vault'
  498. }