-- gitano.config -- -- Load, parse, manage etc configuration for gitano -- -- Note: This is only the admin repo management, not the -- generic repository rules management (see gitano.lace for that). -- -- Copyright 2012-2017 Daniel Silverstone -- All rights reserved. -- -- Redistribution and use in source and binary forms, with or without -- modification, are permitted provided that the following conditions -- are met: -- 1. Redistributions of source code must retain the above copyright -- notice, this list of conditions and the following disclaimer. -- 2. Redistributions in binary form must reproduce the above copyright -- notice, this list of conditions and the following disclaimer in the -- documentation and/or other materials provided with the distribution. -- 3. Neither the name of the author nor the names of their contributors -- may be used to endorse or promote products derived from this software -- without specific prior written permission. -- -- THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND -- ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -- IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -- ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE -- FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -- DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS -- OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) -- HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT -- LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY -- OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF -- SUCH DAMAGE. -- -- local gall = require 'gall' local log = require 'gitano.log' local lace = require 'gitano.lace' local i18n = require 'gitano.i18n' local pat = require 'gitano.patterns' local luxio = require 'luxio' local sio = require 'luxio.simple' local clod = require 'clod' local util = require 'gitano.util' local pcall = pcall local pairs = pairs local tconcat = table.concat local lib_bin_path = "/tmp/DOES_NOT_EXIST" local share_path = "/tmp/DOES_NOT_EXIST" local repo_path = "/tmp/DOES_NOT_EXIST" local admin_name = { realname = "Gitano", email = "gitano@gitano-admin.git" } local required_confs = { site_name = "string", } local function repository() return require 'gitano.repository' end -- Handy Metatable so user.real_name etc works local user_mt = {} function user_mt:__index(key) return self.clod.settings[key] end function user_mt:__newindex(key, value) self.clod.settings[key] = value end local function parse_admin_config(commit) local gittree = commit.content.tree local flat_tree = gall.tree.flatten(gittree.content) local function is_blob(thingy) return thingy and thingy.type and thingy.type == "blob" end if not is_blob(flat_tree["site.conf"]) then return nil, i18n.expand("NO_SITE_CONF") end if not is_blob(flat_tree["rules/core.lace"]) then return nil, i18n.expand("NO_CORE_RULES") end local conf, err = clod.parse(flat_tree["site.conf"].obj.content, "gitano-admin:" .. commit.sha .. ":site.conf") if not conf then return nil, err end -- Parsed site.conf, check for core config entries for k, t in pairs(required_confs) do if type(conf.settings[k]) ~= t then return nil, i18n.expand("ERROR_SITE_CONF_TYPE", {sha=commit.sha, key=k, want=t, got=type(conf.settings[k])}) end end -- Gather the users local users = {} for filename, obj in pairs(flat_tree) do local prefix, username = filename:match(pat.USER_CONF_MATCH) if prefix and username then if not is_blob(obj) then return nil, prefix .. username .. "/user.conf is not a blob?" end if users[username] then return nil, i18n.expand("ERROR_DUPLICATE_USER_NAME", {name=username}) end -- Found a user, fill them out local user_clod, err = clod.parse(obj.obj.content, commit.sha .. ":" .. prefix .. username .. "/user.conf") if not user_clod then return nil, err end if type(user_clod.settings.real_name) ~= "string" then return nil, i18n.expand("ERROR_USER_MISSING_REAL_NAME", { sha=commit.sha, path=prefix..username }) end if (user_clod.settings.email_address and type(user_clod.settings.email_address) ~= "string") then return nil, i18n.expand("ERROR_USER_MISSING_EMAIL_ADDRESS", { sha=commit.sha, path=prefix..username }) end users[username] = setmetatable({ clod = user_clod, keys = {}, meta = { prefix = prefix }, }, user_mt) end end -- Now gather the users' keys local all_keys = {} for filename, obj in pairs(flat_tree) do local prefix, username, keyname = filename:match(pat.USER_KEY_MATCH) if prefix and username and keyname then if not users[username] then return nil, i18n.expand("ERROR_ORPHAN_KEY", {key=keyname, user=username}) end local this_key = obj.obj.content this_key = this_key:gsub("\n*$", "") if this_key:match("\n") then return nil, i18n.expand("ERROR_BAD_KEY_NEWLINES", {filename=filename}) end local keytype, keydata, keytag = this_key:match(pat.SSH_KEY_CONTENTS) if not (keytype and keydata and keytag) then return nil, i18n.expand("ERROR_BAD_KEY_SMELL", {filename=filename}) end if util.ssh_type_is_invalid(keytype) then return nil, i18n.expand("ERROR_BAD_KEY_TYPE", {keytype=keytype, filename=filename}) end if all_keys[this_key] then return nil, i18n.expand( "ERROR_DUPLICATE_KEY", {keyname=keyname, username=username, otherkey=all_keys[this_key].keyname, otheruser=all_keys[this_key].username}) end all_keys[this_key] = { keyname = keyname, username = username } users[username].keys[keyname] = { data = this_key, keyname = keyname, username = username, keytag = keytag, } end end -- Now gather the groups local groups = {} for filename, obj in pairs(flat_tree) do local prefix, groupname = filename:match(pat.GROUP_CONF_MATCH) if prefix and groupname then if groups[groupname] then return nil, i18n.expand("ERROR_DUPLICATE_GROUP", {name=groupname}) end if not is_blob(obj) then return nil, i18n.expand("ERROR_GROUP_NOT_BLOB", {path=prefix..groupname}) end local group_clod, err = clod.parse(obj.obj.content, "gitano-admin:" .. commit.sha .. ":" .. prefix .. groupname .. ".conf", true) if not group_clod then return nil, err end if type(group_clod.settings.description) ~= "string" then return nil, i18n.expand("ERROR_GROUP_NO_DESCRIPTION", {name=groupname}) end local group_globals = { clod = group_clod, settings = group_clod.settings, members = {}, subgroups = {}, } for i, member in ipairs(group_clod:get_list("members")) do group_globals.members[i] = member group_globals.members[member] = i end for i, subgroup in ipairs(group_clod:get_list("subgroups")) do group_globals.subgroups[i] = subgroup group_globals.subgroups[subgroup] = i end function group_globals.changed_tables() group_globals.clod:set_list("members", group_globals.members) group_globals.clod:set_list("subgroups", group_globals.subgroups) end groups[groupname] = group_globals groups[groupname].meta = { prefix = prefix } end end -- Attempt a clean flattening of each group to ensure no loops for gname, gtab in pairs(groups) do local all_members = {} local all_subgroups = {} local function add_group(grname, grtab, path) if not grtab then log.fatal(i18n.expand("ERROR_GROUP_NOT_FOUND", {name=grname, path=path})) return end if all_subgroups[grname] then return nil, i18n.expand("ERROR_GROUP_LOOP", {path=path}) end for _, un in ipairs(grtab.members) do if users[un] then all_members[un] = path end end all_subgroups[grname] = true for _, gn in ipairs(grtab.subgroups) do local ok, msg = add_group(gn, groups[gn], path .. "!" .. gn) if not ok then return nil, msg end end all_subgroups[grname] = false return true end local ok, msg = add_group(gname, gtab, gname) if not ok then return nil, msg end gtab.filtered_members = all_members end -- Now gather the keyrings local keyrings = {} for filename, obj in pairs(flat_tree) do local prefix, keyringname = filename:match(pat.KEYRING_MATCH) if prefix and keyringname then if keyrings[keyringname] then return nil, i18n.expand("ERROR_DUPLICATE_KEYRING", {name=keyringname}) end if not is_blob(obj) then return nil, i18n.expand("ERROR_KEYRING_NOT_BLOB", {path=prefix .. groupname}) end keyrings[keyringname] = { meta = { prefix = prefix }, blob = obj.obj } end end -- Validate that the bypass user (a) exists (b) isn't in any groups -- and (c) has at least one key and/or a password set if users["gitano-bypass"] then for gname, gtab in pairs(groups) do if gtab.filtered_members["gitano-bypass"] then return nil, i18n.expand("ERROR_BYPASS_USER_IN_GROUP", {group=gname}) end end local okay = next(users["gitano-bypass"].keys) or users["gitano-bypass"].clod["passwd.hash"] if not okay then return nil, i18n.expand("ERROR_BYPASS_USER_LACKING") end else return nil, i18n.expand("ERROR_NO_BYPASS_USER_FOUND") end -- Finally, return an object representing this configuration local config = { clod = conf, global = conf.settings, users = users, groups = groups, keyrings = keyrings, content = flat_tree, commit = commit, } local msg config.repo, msg = repository().find(config, 'gitano-admin') if not config.repo then return nil, msg end -- Attempt to parse the lace against the admin repo which uses the core sha config.lace, msg = lace.compile(config.repo) if not config.lace then return nil, msg end -- Configure the logging system local log_prefix = config.global["log.prefix"] if log_prefix ~= nil then log.set_prefix(log_prefix) end return config end local function load_file_content(conf, filename) local entry = conf.content[filename] if not entry then return nil, i18n.expand("ERROR_GENERIC_CONF_NOT_FOUND", {sha=conf.commit.sha, name=filename}) end if entry.type ~= "blob" then return nil, i18n.expand("ERROR_GENERIC_CONF_NOT_BLOB", {sha=conf.sha.commit, name=filename}) end return entry.obj.content, conf.commit.sha .. "::" .. filename end local function has_global_hook(conf, hook) return (conf.content["global-hooks/" .. hook .. ".lua"] ~= nil and conf.content["global-hooks/" .. hook .. ".lua"].type == "blob") end local function get_default_hook_content(conf, filename) return [[ (function(hookf, ...) return hookf(...) end)(...) ]], conf.commit.sha .. "::[[" .. filename .. "]]" end local SSH_KEYS_MARKER_START = "### Gitano Keys ###" local SSH_KEYS_MARKER_END = "### End Gitano Keys ###" local function generate_ssh_config(conf) local ret = {SSH_KEYS_MARKER_START} for u, t in pairs(conf.users) do for ktag, keytab in pairs(t.keys) do log.debug(i18n.expand("DEBUG_ADDING_SSH_KEY", {user=u, key=ktag})) ret[#ret+1] = (('command="%s/gitano-auth \\"%s\\" \\"%s\\" \\"%s\\"",no-agent-forwarding,no-port-forwarding,no-pty,no-user-rc,no-X11-forwarding %s'): format(lib_bin_path, repo_path, u, ktag, keytab.data)) end end ret[#ret+1] = SSH_KEYS_MARKER_END ret[#ret+1] = "" return tconcat(ret, "\n") end local function update_ssh_keys(conf, ssh_path) local ssh_config = generate_ssh_config(conf) if not ssh_path then local home = luxio.getenv "HOME" if not home then log.fatal(i18n.expand("ERROR_CANNOT_FIND_HOME")) end ssh_path = home .. "/.ssh/authorized_keys" end local prefix, suffix = "", "" local rfh, err = sio.open(ssh_path, "r") if rfh then local accum = {} local l = rfh:read("*l") while l ~= nil do if l == SSH_KEYS_MARKER_START then accum[#accum+1] = "" prefix = tconcat(accum, "\n") accum = {} elseif l == SSH_KEYS_MARKER_END then accum = {} else accum[#accum+1] = l end l = rfh:read("*l") end if prefix ~= "" then accum[#accum+1] = "" suffix = tconcat(accum, "\n") end rfh:close() end local create_path = ssh_path .. ".new" local cfh, err = sio.open(create_path, "cew") if not cfh or cfh == -1 then log.fatal(i18n.expand("ERROR_UNABLE_TO_CREATE", {path=create_path})) end cfh:write(prefix) cfh:write(ssh_config) cfh:write(suffix) cfh:close() local ret, errno = luxio.rename(create_path, ssh_path) if ret ~= 0 then log.fatal(i18n.expand("ERROR_UNABLE_TO_RENAME", {path=ssh_path})) end log.chat(i18n.expand("AUTHORIZED_KEYS_UPDATED")) end local function populate_context(conf, ctx, username) if ctx.user and not username then username = ctx.user end ctx.user = username local grps = {} for grp, gtab in pairs(conf.groups) do if gtab.filtered_members[username] then -- Array for pattern matches grps[#grps+1] = grp -- Set for exact matches grps[grp] = true end end ctx.group = grps end local function serialise_conf(tab) local ret = {} local keys = {} for k in pairs(tab) do keys[#keys+1] = k end table.sort(keys) for _, k in ipairs(keys) do ret[#ret+1] = ("%s = %q"):format(k,tab[k]) end ret[#ret+1] = "" return table.concat(ret, "\n") end local function commit_config_changes(conf, desc, author, committer) -- Take extant flat tree, clean out users and groups. -- write out everything we have here, and then prepare -- and write out a commit. local newtree = {} -- Shallow copy the tree ready for mods, skipping keyrings, users and groups for k,v in pairs(conf.content) do if not (k:match(pat.USER_INFO_PREFIX) or k:match(pat.GROUP_INFO_PREFIX) or k:match(pat.KEYRING_INFO_PREFIX)) then newtree[k] = v end end -- Write out the site.conf local obj = conf.repo.git:hash_object("blob", conf.clod:serialise(), true) newtree["site.conf"] = conf.repo.git:get(obj) -- Construct all the users and write them out. for u, utab in pairs(conf.users) do local str = utab.clod:serialise() local obj = conf.repo.git:hash_object("blob", str, true) newtree[utab.meta.prefix .. u .. "/user.conf"] = conf.repo.git:get(obj) -- Now the keys for k, ktab in pairs(utab.keys) do obj = conf.repo.git:hash_object("blob", ktab.data .. "\n", true) newtree[utab.meta.prefix .. u .. "/" .. k .. ".key"] = conf.repo.git:get(obj) end end -- Do the same for the groups for g, gtab in pairs(conf.groups) do obj = conf.repo.git:hash_object("blob", gtab.clod:serialise(), true) newtree[gtab.meta.prefix .. g .. ".conf"] = conf.repo.git:get(obj) end -- And for keyrings for k, ktab in pairs(conf.keyrings) do newtree[ktab.meta.prefix .. k .. ".gpg"] = ktab.blob end local tree, msg = gall.tree.create(conf.repo.git, newtree) if not tree then return nil, msg end author = (author and { realname = conf.users[author].real_name, email = conf.users[author].email_address }) or admin_name committer = (committer and { realname = conf.users[committer].real_name, email = conf.users[committer].email_address }) or author local commit, msg = gall.commit.create(conf.repo.git, { author = author, committer = committer, message = desc or "Updated", tree = tree, parents = { conf.commit } }) if not commit then return nil, msg end -- Verify we can parse the updated configuration repository local newconf, msg = parse_admin_config(commit) if not newconf then return nil, msg end -- Create/Update the HEAD ref local ok, msg = conf.repo.git:update_ref(conf.repo.git.HEAD, commit.sha, nil, conf.commit.sha) if not ok then return nil, msg end -- Okay, updated, so apply the new config... (SSH keys really) update_ssh_keys(newconf) return true, commit end local function get_set_lib_bin_path(p) if p then lib_bin_path = p end return lib_bin_path end local function get_set_share_path(p) if p then share_path = p end return share_path end local function get_set_repo_path(p) if p then repo_path = p end return repo_path end return { genssh = generate_ssh_config, writessh = update_ssh_keys, parse = parse_admin_config, populate_context = populate_context, commit = commit_config_changes, load_file_content = load_file_content, get_default_hook_content = get_default_hook_content, has_global_hook = has_global_hook, lib_bin_path = get_set_lib_bin_path, share_path = get_set_share_path, repo_path = get_set_repo_path, }