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 configfile = "/etc/raddb/radiusd.conf" local owner = "root" local group = "radius" local configtable local macauthfiles -- ################################################################################ -- 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(filecontent) local errtxt if not filecontent then filecontent = {} local res, err = pcall(function() local code, cmdresult = subprocess.call_capture({"radiusd", "-XC"}) if 0 ~= code then errtxt = string.match(cmdresult, "([^\n]+)\n?$") end for line in string.gmatch(cmdresult or "", "[^\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 filecontent[#filecontent+1] = line end end filecontent[#filecontent] = nil end) if not res or err then return nil, err or "Unknown failure" end end local config = {} for i,line in ipairs(filecontent) do if string.match(line, "^%s*#") then elseif string.match(line, "^%s*$") 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 -- 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]) i = i+1 end end return result, i+1 end return (parselines(1)), errtxt end local function replacetags(configtable, value) local tags = {} while string.find(value, "%${") do local tag = string.match(value, "%${%s*([^}]*)%s*}") local tagvalue = "" if tag ~= "" and tags[tag] then tagvalue = tags[tag] elseif tag ~= "" then for i,first in ipairs(configtable) do if string.find(first.name, "^"..tag.."$") then tagvalue = first.value tags[tag] = tagvalue break end end end value = string.gsub(value, "%${%s*"..tag.."%s*}", tagvalue) end return value end local get_passwd_files = function() local files local configs local errtxt if not configtable then configtable,errtxt = get_config() end if configtable then -- Find the files by searching for [modules] / passwd files = {} configs = {} function checkpasswd(value) for k,third in ipairs(value) do if string.find(third.name, "^filename$") then files[#files+1] = third.value configs[#configs+1] = value end end end for i,first in ipairs(configtable) do if string.find(first.name, "^passwd ") then checkpasswd(first.value) elseif string.find(first.name, "^modules$") then for j,second in ipairs(first.value) do if string.find(second.name, "^passwd ") then checkpasswd(second.value) 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,22 do local offset = (string.byte(file:read(1)) % 64) + 1 str = str .. string.sub (b64, offset, offset) end return str end local get_passwd_file = function(self, clientdata, readonly) 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 if readonly then retval.value.filename.readonly = true end local stat = posix.stat(retval.value.filename.value) retval.value.mode = cfe({ label="Permissions", value=stat.mode, seq=2, 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 local get_passwd_entry_private = function(self, clientdata, create) local retval,passwdconfig = get_passwd_file(self, clientdata, true) retval.label = "FreeRADIUS passwd entry" if retval.value.mode and string.find(retval.value.mode.value, "^.%-") then retval.value.filename.errtxt = "Readonly file" return retval end retval.value.mode = nil local entry = 0 local entryline = {} if not create then retval.value.entry = cfe({ label="Entry index", key=true, seq=2 }) self.handle_clientdata(retval, clientdata) entry = tonumber(retval.value.entry.value) or 0 end local hash 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 }) if not create then retval.value.entry.errtxt = "Invalid entry" end local content = fs.read_file(retval.value.filename.value) or "" local maxcount = 0 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 hash = entryline[i] -- 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="Must not be blank", seq=i }) if not create then retval.value.fields.value[tostring(i)].descr="Leave blank to leave unchanged" end retval.value.fields.value["algorithm"..i] = cfe({ type="select", value="$6$", label="Algorithm", option={{value="", label="DES"}, {value="$1$", label="MD5"}, {value="$2a$07$", label="Blowfish"}, {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, hash end local update_passwd_entry_private = function(self, entry, create) -- The filename/entry 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 -- And check for blank password for n,v in pairs(entry.value.fields.value) do if v.option and not modelfunctions.validateselect(v) then entry.errtxt = "Invalid passwd entry" elseif v.type == "password" and v.value == "" and create then v.errtxt = "Cannot be blank" 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 = {} if not create then line = format.string_to_table(content[tonumber(entry.value.entry.value)], entry.value.fields.delimiter) or {} end 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 if create then content[#content+1] = table.concat(values, entry.value.fields.delimiter) else content[tonumber(entry.value.entry.value)] = table.concat(values, entry.value.fields.delimiter) end fs.write_file(entry.value.filename.value, table.concat(content, "\n")) end end end return entry end local get_macauth_files = function() if macauthfiles then return macauthfiles end local errtxt if not configtable then configtable,errtxt = get_config() end if configtable then -- Find the files by searching for [modules] / files / usersfile where key="%{Calling-Station-Id}" macauthfiles = {} function checkfiles(value) local key,file for k,third in ipairs(value) do if string.find(third.name, "^usersfile$") then file = third.value elseif string.find(third.name, "^key$") and string.find(third.value, format.escapemagiccharacters("%{Calling-Station-Id}")) then key = true end if key and file then macauthfiles[#macauthfiles+1] = file break end end end for i,first in ipairs(configtable) do if string.find(first.name, "^files ") then checkfiles(first.value) elseif string.find(first.name, "^modules$") then for j,second in ipairs(first.value) do if string.find(second.name, "^files ") then checkfiles(second.value) end end end end end return macauthfiles, errtxt end local is_valid_macauth_filename = function(filename) macauthfiles = macauthfiles or get_macauth_files() if macauthfiles then for i,f in ipairs(macauthfiles) do if filename == f then return true end end end return false 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 = posix.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 = posix.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 function mymodule.view_passwd_file(self, clientdata) local retval,passwdconfig = get_passwd_file(self, clientdata, true) 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) return (get_passwd_entry_private(self, clientdata, false)) end function mymodule.update_passwd_entry(self, entry) return update_passwd_entry_private(self, entry, false) end function mymodule.get_new_passwd_entry(self, clientdata) return (get_passwd_entry_private(self, clientdata, true)) end function mymodule.create_passwd_entry(self, entry) return update_passwd_entry_private(self, entry, true) end function mymodule.get_delete_passwd_entry(self, clientdata) local retval,passwdconfig = get_passwd_file(self, clientdata) retval.label = "Delete FreeRADIUS passwd entry" if retval.value.mode and string.find(retval.value.mode.value, "^.%-") then retval.value.filename.errtxt = "Readonly file" return retval end retval.value.mode = nil retval.value.entry = cfe({ label="Entry index", seq=2 }) return retval end function mymodule.delete_passwd_entry(self, entry) local success = (nil ~= entry.value.entry) success = modelfunctions.validateselect(entry.value.filename) and success if success then local contenttable = fs.read_file_as_array(entry.value.filename.value) or {} if contenttable[tonumber(entry.value.entry.value) or 0] then table.remove(contenttable, tonumber(entry.value.entry.value)) fs.write_file(entry.value.filename.value, table.concat(contenttable, "\n")) else success = false entry.value.entry.errtxt = "Invalid entry" end end if not success then entry.errtxt = "Failed to delete entry" end return entry end function mymodule.get_passwd(self, clientdata) local retval,passwdconfig = get_passwd_file(self, clientdata, true) retval.label = "FreeRADIUS password" if retval.value.mode and string.find(retval.value.mode.value, "^.%-") then retval.value.filename.errtxt = "Readonly file" return retval end retval.value.mode = nil retval.value.entry = cfe({ label="Entry index", key=true, seq=2 }) self.handle_clientdata(retval, clientdata) if passwdconfig then local success = false -- The filename is valid, need to validate the entry id and presense of "Crypt-Password" field local passwordfield = 0 local usernamefield = 0 retval.value.filename.errtxt = "No password field present" for i,f in ipairs(passwdconfig.fields) do if f == "Crypt-Password" then success = true passwordfield = i retval.value.filename.errtxt = nil elseif f == "User-Name" then usernamefield = i end end local contenttable = fs.read_file_as_array(retval.value.filename.value) or {} local entry = tonumber(retval.value.entry.value) or 0 if contenttable[entry] then local entryline = format.string_to_table(contenttable[entry], format.escapemagiccharacters(passwdconfig.delimiter)) if 0 < passwordfield then if not entryline[passwordfield] or entryline[passwordfield] == "" then success = false retval.value.entry.errtxt = "Password access disabled for this entry" else retval.value.entry.readonly = true end end if 0 < usernamefield then retval.value.username = cfe({ value=entryline[usernamefield] or "", label="User-Name", readonly=true, seq=3 }) end else success = false retval.value.entry.errtxt = "Invalid entry" end if success then retval.value.oldpassword = cfe({ type="password", label="Current Password", seq=4 }) retval.value.password = cfe({ type="password", label="New Password", seq=5 }) retval.value.password_confirm = cfe({ type="password", label="New Password (confirm)", seq=6 }) retval.value.algorithm = cfe({ type="select", value="$6$", label="Algorithm", option={{value="", label="DES"}, {value="$1$", label="MD5"}, {value="$2a$07$", label="Blowfish"}, {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.algorithm.type = "hidden" end end return retval end function mymodule.update_passwd(self, passwd) -- The filename/entry fields have already been validated passwd.errtxt = "Failed to set password" if passwd.value.password and passwd.value.password.value == "" then passwd.value.password.errtxt = "Cannot be blank" elseif passwd.value.entry and passwd.value.password then -- Get the entry form and current password hash local form,pwhash = get_passwd_entry_private(self, {filename=passwd.value.filename.value, entry=passwd.value.entry.value}, false) -- Validate the old password local success = true if (pwhash ~= posix.crypt(passwd.value.oldpassword.value, pwhash)) then success = false passwd.value.oldpassword.errtxt = "Incorrect password" end -- Validate the new password if passwd.value.password.value ~= passwd.value.password_confirm.value then success = false passwd.value.password_confirm.errtxt = "Must match password" end -- Validate the algorithm success = modelfunctions.validateselect(passwd.value.algorithm) and success if success then for n,f in pairs(form.value.fields.value) do if f.label == "Crypt-Password" then f.value = passwd.value.password.value form.value.fields.value["algorithm"..n].value = passwd.value.algorithm.value break end end form = update_passwd_entry_private(self, form, false) passwd.errtxt = form.errtxt end end return passwd end function mymodule.list_macauth_files() local retval = {} local files,errtxt = get_macauth_files() if files then for i,file in ipairs(files) do local details = posix.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 MAC authentication files", errtxt=errtxt }) end function mymodule.get_macauth_file(self, clientdata) local filename = clientdata.filename return modelfunctions.getfiledetails(filename, is_valid_macauth_filename) end function mymodule.update_macauth_file(self, filedetails) local ret = modelfunctions.setfiledetails(self, filedetails, is_valid_macauth_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.get_logfile(self, clientdata) local retval = cfe({ type="group", value={}, label="Log File Configuration" }) retval.value.facility = cfe({value="daemon", label="Syslog Facility"}) retval.value.grep = cfe({ value="radiusd", label="Grep" }) retval.value.filename = cfe({value="/var/log/radius/radius.log", label="File name"}) -- Unfortunately, the output of get_config doesn't seem to have the proper log settings -- so, we need to parse the actual config file local configtable,errtxt = get_config(fs.read_file_as_array(configfile)) if configtable then -- Find the files by searching for main / log files = {} configs = {} for i,first in ipairs(configtable) do if string.find(first.name, "^log$") then for j,second in ipairs(first.value) do if string.find(second.name, "^destination$") then if second.value == "files" then retval.value.facility = nil retval.value.grep = nil else retval.value.filename = nil end elseif string.find(second.name, "^file$") then if retval.value.filename then retval.value.filename.value = replacetags(configtable, second.value) end elseif string.find(second.name, "^syslog_facility$") then if retval.value.facility then retval.value.facility.value = string.lower(second.value) end end end end end -- Default is log to file if retval.value.facility and retval.value.filename then retval.value.facility = nil retval.value.grep = nil end end return retval end return mymodule