From abaa1ce203d44749ca6073cb55c2016b6787deb0 Mon Sep 17 00:00:00 2001 From: Ted Trask Date: Sun, 14 Sep 2014 00:29:35 +0000 Subject: Add listpasswdfiles, viewpasswdfile, and editpasswdentry actions for managing passwd files We obtain the config by running 'radiusd -XC' and parsing the result passwd files are parsed/displayed based upon the format defined in freeradius config Use posix.crypt to set encrypted password generating salt from /dev/urandom, defaulting to SHA-512 --- freeradius3-controller.lua | 12 ++ freeradius3-listpasswdfiles-html.lsp | 63 ++++++++ freeradius3-model.lua | 290 +++++++++++++++++++++++++++++++++++ freeradius3-viewpasswdfile-html.lsp | 58 +++++++ freeradius3.menu | 1 + freeradius3.roles | 4 +- 6 files changed, 426 insertions(+), 2 deletions(-) create mode 100644 freeradius3-listpasswdfiles-html.lsp create mode 100644 freeradius3-viewpasswdfile-html.lsp diff --git a/freeradius3-controller.lua b/freeradius3-controller.lua index fbd149e..5184ac4 100644 --- a/freeradius3-controller.lua +++ b/freeradius3-controller.lua @@ -26,4 +26,16 @@ function mymodule.deletefile(self) return self.handle_form(self, self.model.getdeletefile, self.model.deletefile, self.clientdata, "Delete", "Delete Freeradius File", "Freeradius File Deleted") end +function mymodule.listpasswdfiles(self) + return self.model.list_passwd_files() +end + +function mymodule.viewpasswdfile(self) + return self.model.view_passwd_file(self, self.clientdata) +end + +function mymodule.editpasswdentry(self) + return self.handle_form(self, self.model.get_passwd_entry, self.model.update_passwd_entry, self.clientdata, "Save", "Edit Passwd Entry", "Entry Saved") +end + return mymodule diff --git a/freeradius3-listpasswdfiles-html.lsp b/freeradius3-listpasswdfiles-html.lsp new file mode 100644 index 0000000..a3b454c --- /dev/null +++ b/freeradius3-listpasswdfiles-html.lsp @@ -0,0 +1,63 @@ +<% local view, viewlibrary, page_info, session = ... +htmlviewfunctions = require("htmlviewfunctions") +html = require("acf.html") +%> + +<% +function convertsize(size) + if string.find(size, "k$") then + return tonumber(string.match(size, "[%d.]*")) * 1024.0 + elseif string.find(size, "M$") then + return tonumber(string.match(size, "[%d.]*")) * 1024.0 * 1024.0 + elseif string.find(size, "G$") then + return tonumber(string.match(size, "[%d.]*")) + 1024.0 * 1024.0 * 1024.0 + else + return tonumber(string.match(size, "[%d.]*")) + end +end +%> + + + + + + + +<% local header_level = htmlviewfunctions.displaysectionstart(cfe({label="Passwd Configuration"}), page_info) %> + + + + + + + + +<% local filename = cfe({ type="hidden", value="" }) %> +<% local redir = cfe({ type="hidden", value=page_info.orig_action }) %> +<% for k,v in ipairs( view.value ) do %> + + + + + + +<% end %> +
ActionFileSizeLast Modified
+ <% filename.value = v.filename %> + <% if viewlibrary.check_permission("viewpasswdfile") then %> + <% htmlviewfunctions.displayitem(cfe({type="link", value={filename=filename, redir=redir}, label="", option="View", action="viewpasswdfile"}), page_info, -1) %> + <% end %> + <%= html.html_escape(v.filename) %><%= convertsize(v.size) %>b<%= html.html_escape(v.size) %><%= html.html_escape(v.mtime) %>
+<% htmlviewfunctions.displaysectionend(header_level) %> diff --git a/freeradius3-model.lua b/freeradius3-model.lua index 473219e..1879437 100644 --- a/freeradius3-model.lua +++ b/freeradius3-model.lua @@ -6,6 +6,7 @@ posix = require("posix") fs = require("acf.fs") format = require("acf.format") validator = require("acf.validator") +subprocess = require("subprocess") -- Set variables local processname = "radiusd" @@ -14,6 +15,9 @@ local baseurl = "/etc/raddb" local owner = "radius" local group = "root" +local config +local configtable + -- ################################################################################ -- LOCAL FUNCTIONS @@ -22,6 +26,146 @@ local is_valid_filename = function(filename) return validator.is_valid_filename(filename) and string.match(dirname, baseurl) and not string.match(dirname, "%.%.") end +local get_config = function() + if config then return true end + local code, cmdresult = subprocess.call_capture({"radiusd", "-XC"}) + if 0 ~= code then + return false, string.match(cmdresult, "([^\n]+)\n$") + end + config = {} + for line in string.gmatch(cmdresult, "[^\n]+") do + if string.match(line, "^including") then + elseif string.match(line, "^Using") then + elseif string.match(line, "^reading") then + elseif string.match(line, "^Ignoring") then + --elseif string.match(line, "^Configuration ") then + elseif string.match(line, "^radiusd: ") then + elseif string.match(line, "^rlm_passwd: ") then + elseif string.match(line, "^%[/etc/raddb/") then + elseif string.match(line, "^%s*#") then + elseif string.match(line, "^%c") then + else + -- We want to remove spaces at beginning or end, and comments from end (being careful of quotes) + local tmp = string.match(line, "%S.*") + local nextcomment = string.find(tmp, "#") + local quotestart, quoteend + quotestart, quoteend = string.find(tmp, '%b""') + + while nextcomment and (nextcomment < #tmp) and quotestart and (quotestart < nextcomment) do + if nextcomment < quoteend then + nextcomment = string.find(tmp, "#", nextcomment+1) + else + quotestart, quoteend = string.find(tmp, '%b""', quoteend+1) + end + end + + if nextcomment then + tmp = string.sub(tmp, 1, nextcomment-1) + end + config[#config+1] = tmp + end + end + config[#config] = nil + + -- At this point, every line should have {, =, or } + -- We will parse the lines to create a table structure + function parselines(index) + local result = {} + local i=index + local name,value + while i<#config do + -- Look for = first because line might contain { or } + if string.find(config[i], '=') then + name, value = string.match(config[i], "(.*%S)%s*=%s*(.*)") + if string.find(value, "^\".*\"$") then + value = string.sub(value, 2, -2) + end + result[#result+1] = {name=name, value=value} + i = i+1 + elseif string.find(config[i], '}') then + break + elseif string.find(config[i], '{') then + name = string.match(config[i], "(.*%S)%s*{") + value, i = parselines(i+1) + result[#result+1] = {name=name, value=value} + else + mymodule.logevent("radiusd bad config line:"..config[i]) + end + end + + return result, i+1 + end + + configtable = parselines(1) + config = table.concat(config,"\n") + + return true +end + +local get_passwd_files = function() + local files + local configs + local result,errtxt = get_config() + if result then + -- Find the files by searching for modules / passwd + files = {} + configs = {} + for i,first in ipairs(configtable) do + if string.find(first.name, "^modules$") then + for j,second in ipairs(first.value) do + if string.find(second.name, "^passwd ") then + for k,third in ipairs(second.value) do + if string.find(third.name, "^filename$") then + files[#files+1] = third.value + configs[#configs+1] = second.value + end + end + end + end + end + end + end + return files, errtxt, configs +end + +local parse_passwd_config = function(config) + local retval = {filename="", delimiter=":", fields={}, key={}, allowmultiplekeys=false} + for i,l in ipairs(config) do + if string.match(l.name, "^delimiter$") then + retval.delimiter = l.value + elseif string.match(l.name, "^allowmultiplekeys$") then + retval.allowmultiplekeys = (l.value == "yes") + elseif string.match(l.name, "^format$") then + -- ex. format = "*User-Name:Crypt-Password:" + for field in string.gmatch(l.value, "([^:]*):?") do + field = field or "" + retval.fields[#retval.fields+1] = string.match(field, "^[*~=,]*(.*)") + if string.match(field, "^%*,") then + retval.key.index = #retval.fields + retval.key.multiple = true + elseif string.match(field, "^%*") then + retval.key.index = #retval.fields + retval.key.multiple = false + end + end + end + end + + return retval +end + +local b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789./" +local mksalt = function() + local file = io.open("/dev/urandom") + local str = "" + if file == nil then return nil end + for i = 1,16 do + local offset = (string.byte(file:read(1)) % 64) + 1 + str = str .. string.sub (b64, offset, offset) + end + return str +end + -- ################################################################################ -- PUBLIC FUNCTIONS @@ -115,4 +259,150 @@ function mymodule.deletefile(self, delfile) return delfile end +function mymodule.list_passwd_files() + local retval = {} + local files,errtxt = get_passwd_files() + if files then + for i,file in ipairs(files) do + local details = fs.stat(file) + details.filename = file + table.insert(retval, details) + end + table.sort(retval, function(a,b) return a.filename < b.filename end) + end + + return cfe({ type="structure", value=retval, label="List of Freeradius passwd files", errtxt=errtxt }) +end + +local get_passwd_file = function(self, clientdata) + local retval = cfe({ type="group", value={}, label="Freeradius passwd file" }) + retval.value.filename = cfe({ type="select", label="File name", option ={}, key=true, seq=1 }) + local files,configs,passwdconfig + files,retval.errtxt,configs = get_passwd_files() + if files then + retval.value.filename.option = files + self.handle_clientdata(retval, clientdata) + if retval.value.filename.value then + retval.value.filename.errtxt = "Invalid selection" + for i,f in ipairs(files) do + if f == retval.value.filename.value then + retval.value.filename.errtxt = nil + retval.value.filename.readonly = true + passwdconfig = parse_passwd_config(configs[i]) + break + end + end + end + end + + -- return the CFE structure and the config info for this filename + return retval, passwdconfig +end + +function mymodule.view_passwd_file(self, clientdata) + local retval,passwdconfig = get_passwd_file(self, clientdata) + if passwdconfig then + -- We have a valid filename, now parse the file using the format from the config + retval.value.fields = cfe({ type="structure", value=passwdconfig.fields, label="Fields" }) + retval.value.data = cfe({ type="structure", value={}, label="Data" }) + local content = fs.read_file(retval.value.filename.value) or "" + for line in string.gmatch(content, "[^\n]+") do + retval.value.data.value[#retval.value.data.value+1] = format.string_to_table(line, format.escapemagiccharacters(passwdconfig.delimiter)) + end + end + return retval +end + +function mymodule.get_passwd_entry(self, clientdata) + local retval,passwdconfig = get_passwd_file(self, clientdata) + retval.label = "Freeradius passwd entry" + retval.value.entry = cfe({ label="Entry index", key=true, seq=2 }) + self.handle_clientdata(retval, clientdata) + if passwdconfig then + -- The filename is valid, and we should create the fields + retval.value.fields = cfe({ type="group", value={}, label="Fields", seq=3, delimiter=passwdconfig.delimiter }) + retval.value.entry.errtxt = "Invalid entry" + local content = fs.read_file(retval.value.filename.value) or "" + local maxcount = 0 + local entry = tonumber(retval.value.entry.value) or 0 + local entryline = "" + local i=1 + for line in string.gmatch(content, "[^\n]+") do + local _,count = string.gsub(line, format.escapemagiccharacters(passwdconfig.delimiter), " ") + maxcount = math.max(maxcount, count) + if i == entry then + retval.value.entry.readonly = true + retval.value.entry.errtxt = nil + entryline = format.string_to_table(line, format.escapemagiccharacters(passwdconfig.delimiter)) + end + i = i+1 + end + for i=1, math.max(#passwdconfig.fields, maxcount+1), 1 do + local label = passwdconfig.fields[i] + if not label or label == "" then + label = "Unlabeled field "..i + end + if label == "Crypt-Password" then + -- We do not return the encrypted password, but will leave unchanged if blank + retval.value.fields.value[tostring(i)] = cfe({ type="password", label=label, descr="Leave blank to leave unchanged", seq=i }) + retval.value.fields.value["algorithm"..i] = cfe({ type="select", value="$6$", label="Algorithm", option={{value="", label="DES"}, {value="$1$", label="MD5"}, {value="$2$", label="Blowfish"}, {value="$2a$", label="eksblowfish"}, {value="$5$", label="SHA-256"}, {value="$6$", label="SHA-512"}}, seq=i }) + -- Hide the algorithm so user does not use insecure algorithms unless they REALLY want to + retval.value.fields.value["algorithm"..i].type = "hidden" + else + retval.value.fields.value[tostring(i)] = cfe({ label=label, value=entryline[i] or "", seq=i }) + end + end + end + return retval +end + +function mymodule.update_passwd_entry(self, entry) + -- The password/index fields have already been validated + if not entry.value.fields then + entry.errtxt = "Invalid passwd entry" + else + -- The only fields we can validate are the password algorithms + -- Don't search for 'select' cfe's because they have been changed to hidden + for n,v in pairs(entry.value.fields.value) do + if v.option and not modelfunctions.validateselect(v) then + entry.errtxt = "Invalid passwd entry" + end + end + if not entry.errtxt then + -- Set the value + local content = fs.read_file_as_array(entry.value.filename.value) or {} + local values = {} + for n,v in pairs(entry.value.fields.value) do + if v.type=="password" and v.value=="" then + -- Keep the same password + local line = format.string_to_table(content[tonumber(entry.value.entry.value)], entry.value.fields.delimiter) or {} + values[tonumber(n)] = line[tonumber(n)] or "" + elseif v.type=="password" then + local salt = entry.value.fields.value["algorithm"..n].value + if salt == "" then + salt = string.sub(mksalt(), 1, 2) + else + salt = salt..(mksalt() or "").."$" + end + local crypt,errtxt = posix.crypt(v.value, salt) + if crypt then + values[tonumber(n)] = crypt + else + v.errtxt = errtxt + entry.errtxt = "Invalid passwd entry" + break + end + elseif tonumber(n) then + values[tonumber(n)] = v.value + end + end + if not entry.errtxt then + content[tonumber(entry.value.entry.value)] = table.concat(values, entry.value.fields.delimiter) + fs.write_file(entry.value.filename.value, table.concat(content, "\n")) + end + end + end + return entry +end + return mymodule diff --git a/freeradius3-viewpasswdfile-html.lsp b/freeradius3-viewpasswdfile-html.lsp new file mode 100644 index 0000000..b4e8ce5 --- /dev/null +++ b/freeradius3-viewpasswdfile-html.lsp @@ -0,0 +1,58 @@ +<% local view, viewlibrary, page_info, session = ... +htmlviewfunctions = require("htmlviewfunctions") +html = require("acf.html") +%> + + + + + + + +<% htmlviewfunctions.displaycommandresults({"editpasswdentry"}, session) %> + +<% local header_level = htmlviewfunctions.displaysectionstart(view, page_info) %> +<% htmlviewfunctions.displayitem(view.value.filename) %> +<% if view.value.data then %> + + + +<% for i,f in ipairs(view.value.fields.value) do %> + +<% end %> + + +<% local filename = cfe({ type="hidden", value=view.value.filename.value }) %> +<% local entry = cfe({ type="hidden", value="" }) %> +<% local redir = cfe({ type="hidden", value=page_info.orig_action }) %> +<% -- This is a hack to redirect back to viewing the same file +redir.value = redir.value.."?filename="..html.url_encode(view.value.filename.value) +%> +<% for i,r in ipairs( view.value.data.value ) do %> + + +<% for j,f in ipairs(r) do %> + +<% end %> + +<% end %> +
Action<%= html.html_escape(f) %>
+ <% entry.value = i %> + <% if viewlibrary.check_permission("editpasswdentry") then %> + <% htmlviewfunctions.displayitem(cfe({type="link", value={filename=filename, entry=entry, redir=redir}, label="", option="Edit", action="editpasswdentry"}), page_info, -1) %> + <% end %> + <%= html.html_escape(f) %>
+<% end %> +<% htmlviewfunctions.displaysectionend(header_level) %> diff --git a/freeradius3.menu b/freeradius3.menu index 5dbfa92..463ad8f 100644 --- a/freeradius3.menu +++ b/freeradius3.menu @@ -1,5 +1,6 @@ # Prefix and controller are already known at this point # Cat Group Tab Action Applications 15Freeradius Status status +Applications 15Freeradius Passwd listpasswdfiles Applications 15Freeradius Expert listfiles Applications 15Freeradius Logfile logfile diff --git a/freeradius3.roles b/freeradius3.roles index 49d3fa7..501d761 100644 --- a/freeradius3.roles +++ b/freeradius3.roles @@ -1,3 +1,3 @@ USER=freeradius3:status,freeradius3:startstop,freeradius3:logfile -EXPERT=freeradius3:listfiles,freeradius3:editfile,freeradius3:createfile,freeradius3:deletefile -ADMIN=freeradius3:status,freeradius3:startstop,freeradius3:logfile,freeradius3:listfiles,freeradius3:editfile,freeradius3:createfile,freeradius3:deletefile +EXPERT=freeradius3:listfiles,freeradius3:editfile,freeradius3:createfile,freeradius3:deletefile,freeradius3:listpasswdfiles,freeradius3:viewpasswdfile,freeradius3:editpasswdentry +ADMIN=freeradius3:status,freeradius3:startstop,freeradius3:logfile,freeradius3:listfiles,freeradius3:editfile,freeradius3:createfile,freeradius3:deletefile,freeradius3:listpasswdfiles,freeradius3:viewpasswdfile,freeradius3:editpasswdentry -- cgit v1.2.3