local mymodule = {} -- Load libraries modelfunctions = require("modelfunctions") posix = require("posix") fs = require("acf.fs") format = require("acf.format") validator = require("acf.validator") subprocess = require("subprocess") -- Set variables local processname = "radiusd" local packagename = "freeradius3" local baseurl = "/etc/raddb" local owner = "radius" local group = "root" local config local configtable -- ################################################################################ -- LOCAL FUNCTIONS local is_valid_filename = function(filename) local dirname = posix.dirname(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 function mymodule.get_status() return modelfunctions.getstatus(processname, packagename, "Freeradius Status") end function mymodule.get_startstop(self, clientdata) return modelfunctions.get_startstop(processname) end function mymodule.startstop_service(self, startstop, action) return modelfunctions.startstop_service(startstop, action) end function mymodule.get_file(self, clientdata) local filename = clientdata.filename return modelfunctions.getfiledetails(filename, is_valid_filename) end function mymodule.update_file(self, filedetails) local ret = modelfunctions.setfiledetails(self, filedetails, is_valid_filename) if not ret.errtxt then posix.chmod(filedetails.value.filename.value, "rw-r-----") posix.chown(filedetails.value.filename.value, posix.getpasswd(owner, "uid") or 0, posix.getpasswd(group, "gid") or 0) end return ret end function mymodule.list_files() local retval = {} for file in fs.find(null, baseurl) do local details = fs.stat(file) if details.type == "regular" then details.filename = file table.insert(retval, details) end end table.sort(retval, function(a,b) return a.filename < b.filename end) return cfe({ type="structure", value=retval, label="List of Freeradius files" }) end function mymodule.getnewfile() local filename = cfe({ label="File Name", descr="Must be in "..baseurl }) return cfe({ type="group", value={filename=filename}, label="Freeradius File" }) end function mymodule.createfile(self, filedetails) local success = true local path = string.match(filedetails.value.filename.value, "^%s*(.*%S)%s*$") or "" if not string.find(path, "/") then path = baseurl.."/"..path end if not is_valid_filename(path) then success = false filedetails.value.filename.errtxt = "Invalid filename" else if not fs.is_dir(baseurl) then fs.create_directory(baseurl) end if posix.stat(path) then success = false filedetails.value.filename.errtxt = "Filename already exists" end end if success then fs.create_file(path) else filedetails.errtxt = "Failed to Create File" end return filedetails end function mymodule.getdeletefile(self, clientdata) local retval = {} retval.filename = cfe({ label="File Name", value=clientdata.filename or "" }) return cfe({ type="group", value=retval, label="Delete Freeradius File" }) end function mymodule.deletefile(self, delfile) delfile.errtxt = "Failed to delete Freeradius File - invalid filename" for i,file in ipairs(mymodule.list_files().value) do if delfile.value.filename.value == file.filename then delfile.errtxt = nil os.remove(delfile.value.filename.value) break end end 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