]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
feat: Add Vault KV v2 support for DKIM key management
authorCursor Agent <cursoragent@cursor.com>
Fri, 3 Oct 2025 09:52:51 +0000 (09:52 +0000)
committerCursor Agent <cursoragent@cursor.com>
Fri, 3 Oct 2025 09:52:51 +0000 (09:52 +0000)
Co-authored-by: v <v@rspamd.com>
ChangeLog
lualib/lua_dkim_tools.lua
lualib/rspamadm/vault.lua

index 937f160b65fbf7af590cb14042a87aca29a41ee4..4afca9f993ae39f64175e039124083064fd243a8 100644 (file)
--- a/ChangeLog
+++ b/ChangeLog
@@ -6,6 +6,7 @@
   * [Feature] Redis TLS: Configurable TLS connections in Redis backend
   * [Feature] Map helpers alignment: Enforce 64-byte alignment to prevent unaligned memory access
   * [Feature] Enhanced CLI for secretbox with additional security test coverage
+  * [Feature] Vault: Add support for HashiCorp Vault KV version 2 for DKIM key management
   * [Fix] MIME encoding: Major overhauls and multiple fixes for MIME encoding logic
   * [Fix] MIME encoding: Improved handling and decoding of UTF-8 in MIME headers
   * [Fix] Learning system: Numerous fixes to learn checks and autolearn flag handling
index 37c3b163b86254c115e45b7d91f503b1294dc205..fe13c0c5023496526c49e9adca57460a17857899 100644 (file)
@@ -617,8 +617,22 @@ exports.sign_using_vault = function(N, task, settings, selector, sign_func, err_
   local http = require "rspamd_http"
   local ucl = require "ucl"
 
+  local vault_path = settings.vault_path or 'dkim'
+  local vault_kv_version = settings.vault_kv_version or 1
+
+  -- For KV v2, we need to add 'data' to the path for read operations
+  if vault_kv_version == 2 then
+    local mount_point = vault_path:match('^([^/]+)')
+    local subpath = vault_path:match('^[^/]+/?(.*)')
+    if subpath and subpath ~= '' then
+      vault_path = mount_point .. '/data/' .. subpath
+    else
+      vault_path = mount_point .. '/data'
+    end
+  end
+
   local full_url = string.format('%s/v1/%s/%s',
-    settings.vault_url, settings.vault_path or 'dkim', selector.domain)
+    settings.vault_url, vault_path, selector.domain)
   local upstream_list = lua_util.http_upstreams_by_url(rspamd_config:get_mempool(), settings.vault_url)
 
   local function vault_callback(err, code, body, _)
@@ -638,7 +652,10 @@ exports.sign_using_vault = function(N, task, settings, selector, sign_func, err_
           err_func(task, string.format('vault reply for %s (data=%s) is invalid, no data',
             full_url, body))
         else
-          local elts = obj.data.selectors or {}
+          -- For KV v2, data is nested under obj.data.data
+          -- For KV v1, data is under obj.data
+          local vault_data = vault_kv_version == 2 and obj.data.data or obj.data
+          local elts = vault_data and vault_data.selectors or {}
           local errs = {}
           local nvalid = 0
 
index 840e504e055d7238c5dd45c1c31aaa9ef0d808b5..8933944efc9d0733c986551124d3d924a25952b1 100644 (file)
@@ -49,6 +49,11 @@ parser:option "-o --output"
   yaml = "yaml",
 }
       :default "ucl"
+parser:option "-k --kv-version"
+      :description "Vault KV store version (1 or 2)"
+      :argname("<version>")
+      :convert(tonumber)
+      :default "1"
 
 parser:command "list ls l"
       :description "List elements in the vault"
@@ -123,11 +128,47 @@ local function highlight(str, color)
 end
 
 local function vault_url(opts, path)
+  local vault_path = opts.path
+  
+  -- For KV v2, we need to add 'data' to the path for read/write operations
+  if opts.kv_version == 2 then
+    -- Split the path to inject 'data' after the mount point
+    -- e.g., 'secret/dkim' becomes 'secret/data/dkim'
+    local mount_point = vault_path:match('^([^/]+)')
+    local subpath = vault_path:match('^[^/]+/?(.*)')
+    if subpath and subpath ~= '' then
+      vault_path = mount_point .. '/data/' .. subpath
+    else
+      vault_path = mount_point .. '/data'
+    end
+  end
+  
+  if path then
+    return string.format('%s/v1/%s/%s', opts.addr, vault_path, path)
+  end
+
+  return string.format('%s/v1/%s', opts.addr, vault_path)
+end
+
+local function vault_url_metadata(opts, path)
+  -- For KV v2 metadata operations (like list)
+  local vault_path = opts.path
+  
+  if opts.kv_version == 2 then
+    local mount_point = vault_path:match('^([^/]+)')
+    local subpath = vault_path:match('^[^/]+/?(.*)')
+    if subpath and subpath ~= '' then
+      vault_path = mount_point .. '/metadata/' .. subpath
+    else
+      vault_path = mount_point .. '/metadata'
+    end
+  end
+  
   if path then
-    return string.format('%s/v1/%s/%s', opts.addr, opts.path, path)
+    return string.format('%s/v1/%s/%s', opts.addr, vault_path, path)
   end
 
-  return string.format('%s/v1/%s', opts.addr, opts.path)
+  return string.format('%s/v1/%s', opts.addr, vault_path)
 end
 
 local function is_http_error(err, data)
@@ -198,7 +239,10 @@ local function show_handler(opts, domain)
     os.exit(1)
   else
     maybe_print_vault_data(opts, data.content, function(obj)
-      return obj.data.selectors
+      -- For KV v2, data is nested under obj.data.data
+      -- For KV v1, data is under obj.data
+      local vault_data = opts.kv_version == 2 and obj.data.data or obj.data
+      return vault_data.selectors
     end)
   end
 end
@@ -227,7 +271,8 @@ local function delete_handler(opts, domain)
 end
 
 local function list_handler(opts)
-  local uri = vault_url(opts)
+  -- For KV v2, list operations use the metadata endpoint
+  local uri = opts.kv_version == 2 and vault_url_metadata(opts) or vault_url(opts)
   local err, data = rspamd_http.request {
     config = rspamd_config,
     ev_base = rspamadm_ev_base,
@@ -259,7 +304,7 @@ local function create_and_push_key(opts, domain, existing)
   local uri = vault_url(opts, domain)
   local sk, pk = genkey(opts)
 
-  local res = {
+  local payload = {
     selectors = {
       [1] = {
         selector = opts.selector,
@@ -274,13 +319,16 @@ local function create_and_push_key(opts, domain, existing)
   }
 
   for _, sel in ipairs(existing) do
-    res.selectors[#res.selectors + 1] = sel
+    payload.selectors[#payload.selectors + 1] = sel
   end
 
   if opts.expire then
-    res.selectors[1].valid_end = os.time() + opts.expire * 3600 * 24
+    payload.selectors[1].valid_end = os.time() + opts.expire * 3600 * 24
   end
 
+  -- For KV v2, wrap the payload in a 'data' object
+  local res = opts.kv_version == 2 and { data = payload } or payload
+
   local err, data = rspamd_http.request {
     config = rspamd_config,
     ev_base = rspamadm_ev_base,
@@ -344,7 +392,10 @@ local function newkey_handler(opts, domain)
       os.exit(1)
     end
 
-    local elts = rep.data.selectors
+    -- For KV v2, data is nested under rep.data.data
+    -- For KV v1, data is under rep.data
+    local vault_data = opts.kv_version == 2 and rep.data.data or rep.data
+    local elts = vault_data and vault_data.selectors or nil
 
     if not elts then
       create_and_push_key(opts, domain, {})
@@ -365,7 +416,7 @@ end
 
 local function roll_handler(opts, domain)
   local uri = vault_url(opts, domain)
-  local res = {
+  local payload = {
     selectors = {}
   }
 
@@ -392,7 +443,10 @@ local function roll_handler(opts, domain)
       os.exit(1)
     end
 
-    local elts = rep.data.selectors
+    -- For KV v2, data is nested under rep.data.data
+    -- For KV v1, data is under rep.data
+    local vault_data = opts.kv_version == 2 and rep.data.data or rep.data
+    local elts = vault_data and vault_data.selectors or nil
 
     if not elts then
       printf("No keys to roll for domain %s", domain)
@@ -479,14 +533,17 @@ local function roll_handler(opts, domain)
           nelt.valid_end = os.time() + opts.expire * 3600 * 24
         end
 
-        table.insert(res.selectors, nelt)
+        table.insert(payload.selectors, nelt)
       end
       for _, k in ipairs(keys) do
-        table.insert(res.selectors, k)
+        table.insert(payload.selectors, k)
       end
     end
   end
 
+  -- For KV v2, wrap the payload in a 'data' object
+  local res = opts.kv_version == 2 and { data = payload } or payload
+
   -- We can now store res in the vault
   err, data = rspamd_http.request {
     config = rspamd_config,
@@ -509,7 +566,7 @@ local function roll_handler(opts, domain)
     maybe_print_vault_data(opts, data.content)
     os.exit(1)
   else
-    for _, key in ipairs(res.selectors) do
+    for _, key in ipairs(payload.selectors) do
       if not key.valid_end or key.valid_end > os.time() + opts.ttl * 3600 * 24 then
         maybe_printf(opts, 'rolled key for: %s, new selector: %s', domain, key.selector)
         maybe_printf(opts, 'please place the corresponding public key as following:')