summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel Silverstone <dsilvers@digital-scurf.org>2014-03-06 15:09:14 +0000
committerDaniel Silverstone <dsilvers@digital-scurf.org>2014-03-06 15:09:14 +0000
commit72828dc0db2ac09ab93f4a6631c9da77e5534a55 (patch)
treefb204bdb229601c36aa470902fa7ee1a32f11595
parent499505b70acc410cbf41ce6bf37416738851f49f (diff)
parentf0822b03e200b808588e5a016d188d179f9c0432 (diff)
downloadgitano-72828dc0db2ac09ab93f4a6631c9da77e5534a55.tar.gz
Merge branch 'richardipsum/http' which contains the HTTP work from Richard
Ipsum done for Baserock but rebased to remove Baserock-specific content.
-rw-r--r--Makefile5
-rwxr-xr-xbin/gitano-command.cgi.in91
-rw-r--r--bin/gitano-setup.in2
-rwxr-xr-xbin/gitano-smart-http.cgi.in76
-rw-r--r--lib/gitano.lua2
-rw-r--r--lib/gitano/auth.lua140
-rw-r--r--lib/gitano/config.lua139
-rw-r--r--lib/gitano/log.lua57
-rw-r--r--lib/gitano/repository.lua9
-rw-r--r--lib/gitano/usercommand.lua168
-rw-r--r--lib/gitano/util.lua14
-rwxr-xr-xscripts/htpasswd151
-rw-r--r--skel/gitano-admin/rules/defines.lace3
-rw-r--r--skel/gitano-admin/rules/selfchecks.lace2
14 files changed, 756 insertions, 103 deletions
diff --git a/Makefile b/Makefile
index e81e4a3..7fa7d11 100644
--- a/Makefile
+++ b/Makefile
@@ -21,7 +21,8 @@ MAN_PATH := $(INST_ROOT)/share/man
MAN_INST_PATH := $(DESTDIR)$(MAN_PATH)
LIB_BINS := gitano-auth gitano-post-receive-hook gitano-update-hook \
- gitano-update-ssh gitano-pre-receive-hook
+ gitano-update-ssh gitano-pre-receive-hook gitano-smart-http.cgi \
+ gitano-command.cgi
BINS := gitano-setup
@@ -42,7 +43,7 @@ MODS := gitano \
gitano.actions gitano.config gitano.lace gitano.log \
gitano.markdown gitano.repository gitano.supple \
gitano.command gitano.admincommand gitano.usercommand \
- gitano.repocommand gitano.copycommand
+ gitano.repocommand gitano.copycommand gitano.auth
SKEL_FILES := gitano-admin/rules/selfchecks.lace \
gitano-admin/rules/aschecks.lace \
diff --git a/bin/gitano-command.cgi.in b/bin/gitano-command.cgi.in
new file mode 100755
index 0000000..bc280b2
--- /dev/null
+++ b/bin/gitano-command.cgi.in
@@ -0,0 +1,91 @@
+-- @@SHEBANG
+-- -*- Lua -*-
+-- command cgi
+--
+-- Git (with) Augmented network operations -- User authentication wrapper
+--
+-- Copyright 2014 Codethink Ltd
+--
+--
+
+-- @@GITANO_LUA_PATH
+
+local gitano = require "gitano"
+local gall = require "gall"
+local luxio = require "luxio"
+local sio = require "luxio.simple"
+
+-- @@GITANO_BIN_PATH
+-- @@GITANO_SHARE_PATH
+
+local stdout = sio.stdout
+
+function url_decode(str)
+ str = string.gsub (str, "+", " ")
+ str = string.gsub (str, "%%(%x%x)",
+ function(h) return string.char(tonumber(h,16)) end)
+ str = string.gsub (str, "\r\n", "\n")
+ return str
+end
+
+function run_command(cmd, cmdline, parsed_cmdline, user, config, env)
+ gitano.log.debug("Welcome to " .. config.global.site_name)
+ gitano.log.debug("Running:")
+ for i = 1, #parsed_cmdline do
+ gitano.log.debug(" => " .. parsed_cmdline[i])
+ end
+ gitano.log.debug("")
+ gitano.log.debug("On behalf of " .. user .. " using key " .. env["GITANO_KEYTAG"])
+
+ local how, why = cmd.run(config, repo, parsed_cmdline, env)
+
+ if how ~= "exit" or why ~= 0 then
+ gitano.log.critical("Error running " .. parsed_cmdline[1] .. ": " .. how)
+ return why
+ else
+ gitano.log.syslog.info(cmdline, "completed successfully")
+ return 0
+ end
+end
+
+if os.getenv("QUERY_STRING") then
+ local query_string = url_decode(os.getenv("QUERY_STRING"))
+ local cmdline = query_string
+
+ local _, e = string.find(query_string, "cmd=")
+
+ if not e then
+ stdout:write("Status: 400 Bad request\r\n\r\n")
+ stdout:write("Malformed command line, format: ?cmd=arg0 arg1 ... argn\n")
+ return
+ end
+
+ cmdline = string.sub(query_string, e + 1, #query_string)
+
+ if cmdline == '' then
+ stdout:write("Status: 400 Bad request\r\n\r\n")
+ stdout:write("Malformed command line, format: ?cmd=arg0 arg1 ... argn\n")
+ return
+ end
+
+ local user = os.getenv("REMOTE_USER") or "gitano/anonymous"
+
+ gitano.log.buffer_output()
+
+ local authorized, cmd, parsed_cmdline, config, env =
+ gitano.auth.is_authorized(user, "http", cmdline)
+
+ if authorized then
+ local exit = run_command(cmd, cmdline, parsed_cmdline, user, config, env)
+
+ stdout:write("Status: " .. (exit == 0 and "200 OK" or "400 Bad request")
+ .. "\r\n\r\n")
+ stdout:write(gitano.log.get_buffered_output() or "")
+ else
+ stdout:write("Status: 403 Forbidden\r\n\r\n")
+ stdout:write(gitano.log.get_buffered_output() or "")
+ end
+else
+ stdout:write("Status: 400 Bad request\r\n\r\n")
+ stdout:write("Malformed command line, format: ?cmd=arg0 arg1 ... argn\n")
+end
diff --git a/bin/gitano-setup.in b/bin/gitano-setup.in
index fbfa58a..61a3246 100644
--- a/bin/gitano-setup.in
+++ b/bin/gitano-setup.in
@@ -161,6 +161,7 @@ validate_name(ask_for("admin.keyname", "Key name for administrator",
ask_for("site.name", "Site name", "a random Gitano instance")
ask_for("log.prefix", "Site log prefix", "gitano")
+ask_for("use.htpasswd", "Store passwords with htpasswd? (needed for http authentication)", "no")
gitano.log.chat("Step 2: Gather required content")
@@ -169,6 +170,7 @@ local completely_flat = {}
local site_conf = clod.parse("")
site_conf.settings["site_name"] = get "site.name"
site_conf.settings["log.prefix"] = get "log.prefix"
+site_conf.settings["use_htpasswd"] = get "use.htpasswd"
completely_flat["site.conf"] = site_conf:serialise()
-- Acquire the contents of the skeleton gitano-admin repository
diff --git a/bin/gitano-smart-http.cgi.in b/bin/gitano-smart-http.cgi.in
new file mode 100755
index 0000000..8fb0240
--- /dev/null
+++ b/bin/gitano-smart-http.cgi.in
@@ -0,0 +1,76 @@
+-- @@SHEBANG
+-- -*- Lua -*-
+-- gitano-smart-http
+--
+-- Git (with) Augmented network operations -- User authentication wrapper
+--
+-- Copyright 2014 Codethink Ltd
+--
+--
+
+-- @@GITANO_LUA_PATH
+
+local gitano = require "gitano"
+local gall = require "gall"
+local luxio = require "luxio"
+local subprocess = require "luxio.subprocess"
+local sio = require "luxio.simple"
+
+-- @@GITANO_BIN_PATH
+-- @@GITANO_SHARE_PATH
+
+local stdout = sio.stdout
+
+function parse_get_request()
+ query_string = os.getenv("QUERY_STRING")
+
+ if query_string then
+ command = string.gsub(query_string, "^service=", "")
+ repo = string.match(os.getenv("PATH_INFO"), '/(.+)/info/refs')
+ return command .. " '" .. repo .. "'"
+ end
+
+ return nil
+end
+
+function parse_post_request()
+ path_info = os.getenv("PATH_INFO")
+
+ if path_info then
+ repo, command = string.match(path_info, "/(.+)/(.+)")
+ return command .. " '" .. repo .. "'"
+ end
+
+ return nil
+end
+
+function parse_request(request_method)
+ if request_method == "GET" then
+ return parse_get_request()
+ elseif request_method == "POST" then
+ return parse_post_request()
+ end
+end
+
+request_method = os.getenv("REQUEST_METHOD")
+
+if request_method == "GET" or request_method == "POST" then
+ local user = os.getenv("REMOTE_USER") or "gitano/anonymous"
+ local cmdline = parse_request(request_method)
+
+ if cmdline and gitano.auth.is_authorized(user, "http", cmdline) then
+ local proc = subprocess.spawn_simple({"git", "http-backend"})
+ local exit_code
+
+ _, exit_code = proc:wait()
+
+ if exit_code ~= 0 then
+ stdout:write("Status: 500 Internal Server Error\r\n\r\n")
+ end
+ else
+ stdout:write("Status: 403 Forbidden\r\n\r\n")
+ end
+else
+ stdout:write("Status: 405 Method Not Allowed\r\n")
+ stdout:write("Allow: GET, POST\r\n\r\n")
+end
diff --git a/lib/gitano.lua b/lib/gitano.lua
index ad3cd7c..b57bd71 100644
--- a/lib/gitano.lua
+++ b/lib/gitano.lua
@@ -14,6 +14,7 @@ local actions = require 'gitano.actions'
local lace = require 'gitano.lace'
local markdown = require 'gitano.markdown'
local supple = require 'gitano.supple'
+local auth = require 'gitano.auth'
return {
util = util,
@@ -25,4 +26,5 @@ return {
lace = lace,
markdown = markdown,
supple = supple,
+ auth = auth
}
diff --git a/lib/gitano/auth.lua b/lib/gitano/auth.lua
new file mode 100644
index 0000000..8cdd8ec
--- /dev/null
+++ b/lib/gitano/auth.lua
@@ -0,0 +1,140 @@
+local config = require 'gitano.config'
+local command = require 'gitano.command'
+local log = require 'gitano.log'
+local repository = require 'gitano.repository'
+local util = require 'gitano.util'
+local gall = require 'gall'
+
+local function load_admin_conf(repo_root)
+ local admin_repo = gall.repository.new((repo_root or "") ..
+ "/gitano-admin.git")
+
+ if not admin_repo then
+ log.critical("Unable to locate administration repository.")
+ return nil
+ end
+
+ local admin_head = admin_repo:get(admin_repo.HEAD)
+
+ if not admin_head then
+ log.critical("Unable to find the HEAD of the administration repository.")
+ return nil
+ end
+
+ local admin_conf, msg = config.parse(admin_head)
+
+ if not admin_conf then
+ log.critical("Unable to parse administration repository.")
+ log.critical(" * " .. (msg or "No error?"))
+ return nil
+ end
+
+ return admin_conf
+end
+
+local function set_environment(repo_root, repo, context, transactionid)
+ local env = {
+ ["GITANO_ROOT"] = repo_root,
+ ["GITANO_USER"] = context.user,
+ ["GITANO_KEYTAG"] = context.keytag,
+ ["GITANO_PROJECT"] = (repo or {}).name or "",
+ ["GITANO_SOURCE"] = context.source,
+ ["GITANO_TRANSACTION_ID"] = transactionid,
+ }
+
+ for k, v in pairs(env) do
+ luxio.setenv(k, v)
+ end
+
+ return env
+end
+
+local function is_authorized(user, source, cmdline)
+ local repo_root = os.getenv("GITANO_ROOT")
+ local keytag = ""
+ local authorized = false
+
+ local start_log_level = log.get_level()
+ log.cap_level(log.level.INFO)
+ local transactionid = log.syslog.open()
+
+ config.repo_path(repo_root)
+
+ if not user or not cmdline then
+ return nil
+ end
+
+ local parsed_cmdline, warnings = util.parse_cmdline(cmdline)
+
+ if (#warnings > 0) then
+ log.error("Error parsing command");
+ return nil
+ end
+
+ local admin_conf = load_admin_conf(repo_root)
+ if admin_conf == nil then
+ log.fatal("Couldn't load a config from the admin repository")
+ end
+
+ if admin_conf.groups["gitano-admin"].filtered_members[user] then
+ log.set_level(start_log_level)
+ end
+
+ if not admin_conf.global.silent then
+ log.bump_level(log.level.CHAT)
+ end
+
+ ip = os.getenv("REMOTE_ADDR") or "unknown ip"
+ log.syslog.info("Client connected from", ip, "as", user,
+ "(" .. keytag .. ")", "Executing command:", cmdline)
+
+ local cmd = command.get(parsed_cmdline[1])
+
+ if not cmd then
+ log.critical("Unknown command: " .. parsed_cmdline[1])
+ return nil
+ end
+
+ local repo
+ if cmd.takes_repo and #parsed_cmdline > 1 then
+ -- Acquire the repository object for the target repo
+ local msg
+ repo, msg = repository.find(admin_conf, parsed_cmdline[2])
+
+ if not repo then
+ log.critical("Unable to locate repository.")
+ log.critical(" * " .. (tostring(msg) or "No error"))
+ return nil
+ end
+ end
+
+ if not cmd.validate(admin_conf, repo, parsed_cmdline) then
+ log.critical("Validation of command line failed")
+ return nil
+ end
+
+ local context = {source = source, user = user, keytag = keytag}
+ local action, reason = cmd.prep(admin_conf, repo, parsed_cmdline, context)
+
+ if not action then
+ log.critical(reason)
+ log.critical("Ruleset did not complete cleanly")
+ return nil
+ end
+
+ local env
+ if action == "allow" then
+ log.info(reason or "Ruleset permitted action")
+ authorized = true
+ env = set_environment(repo_root, repo, context, transactionid)
+ else
+ log.critical(reason)
+ log.critical("Ruleset denied action. Sorry.")
+ end
+
+ return authorized, cmd, parsed_cmdline, admin_conf, env
+end
+
+return {
+ is_authorized = is_authorized
+}
diff --git a/lib/gitano/config.lua b/lib/gitano/config.lua
index 865e222..afa9072 100644
--- a/lib/gitano/config.lua
+++ b/lib/gitano/config.lua
@@ -82,32 +82,31 @@ local function parse_admin_config(commit)
for filename, obj in pairs(flat_tree) do
local prefix, username = filename:match("^(users/.-)([a-z][a-z0-9_-]+)/user%.conf$")
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, "Duplicate user 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, "gitano-admin:" .. commit.sha .. ":" .. prefix .. username .. "/user.conf missing real_name"
- end
- if (user_clod.settings.email_address and
- type(user_clod.settings.email_address) ~= "string") then
- return nil, "gitano-admin:" .. commit.sha .. ":" .. prefix .. username .. "/user.conf email_address is bad"
- end
- users[username] = setmetatable({ clod = user_clod,
- keys = {},
- meta = { prefix = prefix },
- }, user_mt)
+ if not is_blob(obj) then
+ return nil, prefix .. username .. "/user.conf is not a blob?"
+ end
+ if users[username] then
+ return nil, "Duplicate user 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, "gitano-admin:" .. commit.sha .. ":" .. prefix .. username .. "/user.conf missing real_name"
+ end
+ if (user_clod.settings.email_address and
+ type(user_clod.settings.email_address) ~= "string") then
+ return nil, "gitano-admin:" .. commit.sha .. ":" .. prefix .. username .. "/user.conf email_address is bad"
+ end
+
+ users[username] = setmetatable({ clod = user_clod, keys = {},
+ meta = { prefix = prefix }, }, user_mt)
end
end
@@ -116,41 +115,41 @@ local function parse_admin_config(commit)
for filename, obj in pairs(flat_tree) do
local prefix, username, keyname = filename:match("^(users/.-)([a-z][a-z0-9_-]+)/([a-z][a-z0-9_-]+)%.key$")
if prefix and username and keyname then
- if not users[username] then
- return nil, "Found a key (" .. keyname .. ") for " .. username .. " which lacks a user.conf"
- end
- local this_key = obj.obj.content
-
- this_key = this_key:gsub("\n*$", "")
-
- if this_key:match("\n") then
- return nil, "Key " .. filename .. " has newlines in it -- is it in the wrong format?"
- end
-
- local keytype, keydata, keytag = this_key:match("^([^ ]+) ([^ ]+) ([^ ].*)$")
- if not (keytype and keydata and keytag) then
- return nil, "Unable to parse key, " .. filename .. " did not smell like an OpenSSH v2 key"
- end
- if (keytype ~= "ssh-rsa") and (keytype ~= "ssh-dss") and
- (keytype ~= "ecdsa-sha2-nistp256") and
- (keytype ~= "ecdsa-sha2-nistp384") and
- (keytype ~= "ecdsa-sha2-nistp521") then
- return nil, "Unknown key type " .. keytype .. " in " .. filename
- end
-
- if all_keys[this_key] then
- return nil, ("Duplicate key found at (" .. keyname ..
- ") for " .. username .. ". Previously found as (" ..
- all_keys[this_key].keyname .. ") for " ..
- 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,
- }
+ if not users[username] then
+ return nil, "Found a key (" .. keyname .. ") for " .. username .. " which lacks a user.conf"
+ end
+ local this_key = obj.obj.content
+
+ this_key = this_key:gsub("\n*$", "")
+
+ if this_key:match("\n") then
+ return nil, "Key " .. filename .. " has newlines in it -- is it in the wrong format?"
+ end
+
+ local keytype, keydata, keytag = this_key:match("^([^ ]+) ([^ ]+) ([^ ].*)$")
+ if not (keytype and keydata and keytag) then
+ return nil, "Unable to parse key, " .. filename .. " did not smell like an OpenSSH v2 key"
+ end
+ if (keytype ~= "ssh-rsa") and (keytype ~= "ssh-dss") and
+ (keytype ~= "ecdsa-sha2-nistp256") and
+ (keytype ~= "ecdsa-sha2-nistp384") and
+ (keytype ~= "ecdsa-sha2-nistp521") then
+ return nil, "Unknown key type " .. keytype .. " in " .. filename
+ end
+
+ if all_keys[this_key] then
+ return nil, ("Duplicate key found at (" .. keyname .. ") for " ..
+ username .. ". Previously found as (" ..
+ all_keys[this_key].keyname .. ") for " ..
+ 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
@@ -369,30 +368,32 @@ local function commit_config_changes(conf, desc, username)
-- write out everything we have here, and then prepare
-- and write out a commit.
local newtree = {}
+
-- Shallow copy the tree ready for mods, skipping users and groups
for k,v in pairs(conf.content) do
if not (k:match("^users/") or
- k:match("^groups/")) then
- newtree[k] = v
+ k:match("^groups/")) then
+ newtree[k] = v
end
end
+
-- Write out the site.conf
- local obj = conf.repo.git:hash_object("blob",
- conf.clod:serialise(),
- true)
+ 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)
+ 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)
diff --git a/lib/gitano/log.lua b/lib/gitano/log.lua
index f243b87..e1df00b 100644
--- a/lib/gitano/log.lua
+++ b/lib/gitano/log.lua
@@ -14,6 +14,7 @@ local prefix = "[gitano] "
local transactionid = nil
local stream = sio.stderr
+local is_buffered = false
local ERRS = 0
local WARN = 1
@@ -24,6 +25,40 @@ local DEEPDEBUG = 5
local level = ERRS
+local LogBuf = {}
+LogBuf.__index = LogBuf
+
+function LogBuf:new()
+ return setmetatable({strings = {}}, self)
+end
+
+function LogBuf:write(s)
+ table.insert(self.strings, s)
+end
+
+function LogBuf:get()
+ return table.concat(self.strings)
+end
+
+local function is_buffered_output()
+ return is_buffered
+end
+
+local function buffer_output()
+ if not is_buffered_output() then
+ stream = LogBuf:new()
+ is_buffered = true
+ end
+end
+
+local function get_buffered_output()
+ if is_buffered_output() then
+ return stream:get()
+ else
+ return nil
+ end
+end
+
local function syslog_write(priority, ...)
local strs = {...}
@@ -98,15 +133,26 @@ end
local function stdout(...)
local savedstream, savedprefix = stream, prefix
- stream, prefix = sio.stdout, ""
+
+ prefix = ""
+ if not is_buffered_output() then
+ stream = sio.stdout
+ end
+
state(...)
stream, prefix = savedstream, savedprefix
end
local function fatal(...)
- syslog_write(luxio.LOG_EMERG, ...)
+ syslog_write(luxio.LOG_CRIT, ...)
AT(ERRS, "FATAL:", ...)
- stream:close()
+
+ if is_buffered_output() then
+ sio.stderr:write(get_buffered_output())
+ else
+ stream:close()
+ end
+
luxio._exit(1)
end
@@ -244,5 +290,8 @@ return {
info = syslog_info,
debug = syslog_debug,
close = syslog_close,
- }
+ },
+ buffer_output = buffer_output,
+ is_buffered_output = is_buffered_output,
+ get_buffered_output = get_buffered_output
}
diff --git a/lib/gitano/repository.lua b/lib/gitano/repository.lua
index 5d4a194..a15fcbe 100644
--- a/lib/gitano/repository.lua
+++ b/lib/gitano/repository.lua
@@ -183,8 +183,13 @@ function repo_method:check_local_git_files()
end
if anonexport then
log.ddebug("<" .. self.name .. ">: Anonymous read allowed")
- local fh = sio.open(self:fs_path() .. "/git-daemon-export-ok", "wc")
- fh:close()
+ local fh, errmsg = sio.open(self:fs_path() .. "/git-daemon-export-ok", "wc")
+
+ if fh then
+ fh:close()
+ else
+ log.warn("Can't create git-daemon-export file:", errmsg)
+ end
else
log.ddebug("<" .. self.name .. ">: Anonymous read not allowed")
luxio.unlink(self:fs_path() .. "/git-daemon-export-ok")
diff --git a/lib/gitano/usercommand.lua b/lib/gitano/usercommand.lua
index d28b203..3c8b467 100644
--- a/lib/gitano/usercommand.lua
+++ b/lib/gitano/usercommand.lua
@@ -10,6 +10,7 @@ local repository = require 'gitano.repository'
local config = require 'gitano.config'
local sio = require 'luxio.simple'
+local subprocess = require 'luxio.subprocess'
local builtin_whoami_short = "Find out how Gitano identifies you"
@@ -20,15 +21,19 @@ Tells you who you are, what your email address is set to, what keys you have
registered etc.
]]
-local function builtin_whoami_validate(config, repo, cmdline)
- -- whoami
+local function validate_single_argcmd(cmdline, msg)
if #cmdline > 1 then
- log.error("usage: whoami")
+ log.error(msg)
return false
end
+
return true
end
+local function builtin_whoami_validate(_, _, cmdline)
+ return validate_single_argcmd(cmdline, "usage: whoami")
+end
+
local function builtin_whoami_prep(config, repo, cmdline, context)
context.operation = "whoami"
return config.repo:run_lace(context)
@@ -37,6 +42,11 @@ end
local function builtin_whoami_run(config, repo, cmdline, env)
local username = env["GITANO_USER"]
local userdata = config.users[username]
+
+ if not userdata then
+ return "I don't know who you are", 1
+ end
+
log.stdout(" User name:", username)
log.stdout(" Real name:", userdata.real_name or "Unknown")
log.stdout("Email address:", userdata.email_address or "unknown@example.com")
@@ -159,53 +169,158 @@ local function builtin_sshkey_run(conf, _, cmdline, env)
local utab = conf.users[env.GITANO_USER]
if cmdline[2] == "list" then
if not next(utab.keys) then
- log.warn("There are no SSH keys registered for", env.GITANO_USER .. ", sorry")
+ log.warn("There are no SSH keys registered for", env.GITANO_USER
+ .. ", sorry")
else
- local pfx = " SSH key:"
- for tagname, keydata in pairs(utab.keys) do
- local suffix = (env.GITANO_KEYTAG == tagname) and " [*]" or ""
- log.state(pfx, tagname, "=>", keydata.keytag .. suffix)
- pfx = " "
- end
+ local pfx = " SSH key:"
+ for tagname, keydata in pairs(utab.keys) do
+ local suffix = (env.GITANO_KEYTAG == tagname) and " [*]" or ""
+ log.state(pfx, tagname, "=>", keydata.keytag .. suffix)
+ pfx = " "
+ end
end
elseif cmdline[2] == "add" then
local sshkey = sio.stdin:read("*l")
local keytype, keydata, keytag = sshkey:match("^([^ ]+) ([^ ]+) ([^ ].*)$")
if not (keytype and keydata and keytag) then
- log.error("Unable to parse key,", filename,
- "did not smell like an OpenSSH v2 key")
- return "exit", 1
+ log.error("Unable to parse key,", filename,
+ "did not smell like an OpenSSH v2 key")
+ return "exit", 1
end
+
if (keytype ~= "ssh-rsa") and (keytype ~= "ssh-dss") and
- (keytype ~= "ecdsa-sha2-nistp256") and
- (keytype ~= "ecdsa-sha2-nistp384") and
- (keytype ~= "ecdsa-sha2-nistp521") then
- log.error("Unknown key type", keytype)
- return "exit", 1
+ (keytype ~= "ecdsa-sha2-nistp256") and
+ (keytype ~= "ecdsa-sha2-nistp384") and
+ (keytype ~= "ecdsa-sha2-nistp521") then
+ log.error("Unknown key type", keytype)
+ return "exit", 1
end
+
local keytab = {
- data = sshkey,
- keyname = cmdline[3],
- username = env.GITANO_USER,
- keytag = keytag,
+ data = sshkey,
+ keyname = cmdline[3],
+ username = env.GITANO_USER,
+ keytag = keytag,
}
+
utab.keys[cmdline[3]] = keytab
-
elseif cmdline[2] == "del" then
utab.keys[cmdline[3]] = nil
end
if cmdline[2] ~= "list" then
-- Store the config back.
+
local action = (cmdline[2] == "add") and "Added" or "Deleted"
action = action .. " " .. cmdline[3] .. " for " .. env.GITANO_USER
local ok, msg = config.commit(conf, action, env.GITANO_USER)
+
+ if not ok then
+ log.error(msg)
+ return "exit", 1
+ end
+ end
+
+ return "exit", 0
+end
+
+local builtin_passwd_short = "Set your password"
+
+local builtin_passwd_helptext = [[
+usage: passwd
+
+Sets your password, the password is read from stdin.
+
+If no password is provided your password is removed (if you have one).
+]]
+
+local function builtin_passwd_validate(_, _, cmdline)
+ return validate_single_argcmd(cmdline, "usage: passwd")
+end
+
+local function builtin_passwd_prep(conf, repo, cmdline, context)
+ context.operation = "passwd"
+
+ local action, reason = conf.repo:run_lace(context)
+ if action == "deny" then
+ return reason
+ end
+
+ return action, reason
+end
+
+local function update_htpasswd(user, passwd)
+ local htpasswd_path = os.getenv("HOME") .. "/htpasswd"
+ local flags = io.open(htpasswd_path, "r") and "" or "-c"
+ local exit_code
+
+ if passwd ~= '' then
+ local proc = subprocess.spawn_simple({
+ "htpasswd", flags, htpasswd_path, user,
+ stdin = passwd .. '\n' .. passwd .. '\n',
+ stdout = subprocess.PIPE,
+ stderr = subprocess.PIPE
+ })
+
+ _, exit_code = proc:wait()
+ else
+ local proc = subprocess.spawn_simple({
+ "htpasswd", "-D", htpasswd_path, user,
+ stdout = subprocess.PIPE,
+ stderr = subprocess.PIPE
+ })
+
+ _, exit_code = proc:wait()
+ end
+
+ return exit_code == 0
+end
+
+local function builtin_passwd_run(conf, _, cmdline, env)
+ local user = env.GITANO_USER
+
+ local password = sio.stdin:read("*l")
+ local method, hash = util.hash_password(password)
+
+ if conf.users[user].hash == nil and password == "" then
+ log.chat(string.format("Password for %s is not set and no password was"
+ .. " provided, no action taken.", user))
+ return "exit", 0
+ end
+
+ if password ~= "" then
+ conf.users[user].method = method
+ conf.users[user].hash = hash
+ else
+ -- user's password will be removed
+ conf.users[user].method = nil
+ conf.users[user].hash = nil
+ end
+
+ local ok, msg
+
+ if conf.clod.settings["use_htpasswd"] == "yes" then
+ ok = update_htpasswd(user, password)
+
if not ok then
- log.error(msg)
- return "exit", 1
+ log.error("Failed to update htpasswd file")
+ return "exit", 1
end
end
+ local action = string.format("%s password for %s",
+ password ~= '' and "Update" or "Remove", user)
+
+ ok, msg = config.commit(conf, action, user)
+
+ if not ok then
+ log.error(msg)
+ return "exit", 1
+ end
+
+ log.chat(string.format("%s password for %s",
+ password ~= '' and "Updated" or "Removed", user))
+
return "exit", 0
end
@@ -216,6 +331,9 @@ local function register_commands(reg)
assert(reg("sshkey", builtin_sshkey_short, builtin_sshkey_helptext,
builtin_sshkey_validate, builtin_sshkey_prep,
builtin_sshkey_run, false, false))
+ assert(reg("passwd", builtin_passwd_short, builtin_passwd_helptext,
+ builtin_passwd_validate, builtin_passwd_prep,
+ builtin_passwd_run, false, false))
end
return {
diff --git a/lib/gitano/util.lua b/lib/gitano/util.lua
index 51c5bc2..c2a53a7 100644
--- a/lib/gitano/util.lua
+++ b/lib/gitano/util.lua
@@ -9,9 +9,20 @@
local luxio = require 'luxio'
local sio = require 'luxio.simple'
local log = require 'gitano.log'
+local scrypt = require 'scrypt'
local tconcat = table.concat
+local check_password = scrypt.verify_password
+
+local function hash_password(password)
+ -- For the moment we are using scrypt,
+ -- we may decide to use other hash functions in the future
+ -- so it's useful to provide some way to identify which hash
+ -- function was used
+ return 'scrypt', scrypt.hash_password(password, 2^14, 8, 1)
+end
+
local function _deep_copy(t, memo)
if not memo then memo = {} end
if memo[t] then return memo[t] end
@@ -480,4 +491,7 @@ return {
process_expansion = process_expansion,
set = set,
+
+ hash_password = hash_password,
+ check_password = check_password,
}
diff --git a/scripts/htpasswd b/scripts/htpasswd
new file mode 100755
index 0000000..a28ba2a
--- /dev/null
+++ b/scripts/htpasswd
@@ -0,0 +1,151 @@
+#!/usr/bin/env python
+# Based on FreeBSD src/lib/libcrypt/crypt.c 1.2
+# http://www.freebsd.org/cgi/cvsweb.cgi/~checkout~/src/lib/libcrypt/crypt.c?rev=1.2&content-type=text/plain
+#
+# Original license:
+# * "THE BEER-WARE LICENSE" (Revision 42):
+# * <phk@login.dknet.dk> wrote this file. As long as you retain this notice you
+# * can do whatever you want with this stuff. If we meet some day, and you think
+# * this stuff is worth it, you can buy me a beer in return. Poul-Henning Kamp
+#
+# This port adds no further stipulations. I forfeit any copyright interest.
+
+from __future__ import print_function
+import md5
+import random
+import string
+import sys
+import getpass
+
+def hash(password, salt, magic='$apr1$'):
+ # /* The password first, since that is what is most unknown */ /* Then our magic string */ /* Then the raw salt */
+ m = md5.new()
+ m.update(password + magic + salt)
+
+ # /* Then just as many characters of the MD5(pw,salt,pw) */
+ mixin = md5.md5(password + salt + password).digest()
+ for i in range(0, len(password)):
+ m.update(mixin[i % 16])
+
+ # /* Then something really weird... */
+ # Also really broken, as far as I can tell. -m
+ i = len(password)
+ while i:
+ if i & 1:
+ m.update('\x00')
+ else:
+ m.update(password[0])
+ i >>= 1
+
+ final = m.digest()
+
+ # /* and now, just to make sure things don't run too fast */
+ for i in range(1000):
+ m2 = md5.md5()
+ if i & 1:
+ m2.update(password)
+ else:
+ m2.update(final)
+
+ if i % 3:
+ m2.update(salt)
+
+ if i % 7:
+ m2.update(password)
+
+ if i & 1:
+ m2.update(final)
+ else:
+ m2.update(password)
+
+ final = m2.digest()
+
+ # This is the bit that uses to64() in the original code.
+
+ itoa64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
+
+ rearranged = ''
+ for a, b, c in ((0, 6, 12), (1, 7, 13), (2, 8, 14), (3, 9, 15), (4, 10, 5)):
+ v = ord(final[a]) << 16 | ord(final[b]) << 8 | ord(final[c])
+ for i in range(4):
+ rearranged += itoa64[v & 0x3f]; v >>= 6
+
+ v = ord(final[11])
+ for i in range(2):
+ rearranged += itoa64[v & 0x3f]; v >>= 6
+
+ return magic + salt + '$' + rearranged
+
+def usage():
+ print('%s: usage: %s [-cD] passwdfile username' %
+ (sys.argv[0], sys.argv[0]), file=sys.stderr)
+
+def salt(len):
+ return ''.join([random.choice(string.ascii_letters + string.digits)
+ for x in range(len)])
+
+def write_passwords(passwords, path):
+ with open(path, 'w') as f:
+ for (username, pwhash) in passwords:
+ f.write('%s:%s\n' % (username, pwhash))
+
+def ask_password():
+ x = getpass.getpass('New password: ')
+ y = getpass.getpass('Re-type password: ')
+
+ return x if x == y else None
+
+if len(sys.argv) not in [3, 4]:
+ if len(sys.argv) == 4 and sys.argv[1] not in ['-c', '-D', '-cD', '-Dc']:
+ usage()
+ sys.exit(2)
+
+flags = len(sys.argv) == 4
+create_flag = flags and 'c' in sys.argv[1]
+delete_flag = flags and 'D' in sys.argv[1]
+
+if create_flag and delete_flag:
+ print('%s: -c and -D options conflict' % sys.argv[0], file=sys.stderr)
+ sys.exit(2)
+
+file_path = sys.argv[flags + 1]
+username = sys.argv[flags + 2]
+
+if not delete_flag:
+ password = ask_password()
+
+ if password == None:
+ exit("%s: passwords weren't the same" % sys.argv[0])
+
+contents = []
+found = False
+
+if not create_flag:
+ with open(file_path, 'r') as f:
+ # read in the existing passwd file
+ # replace entry for 'username' with entry containing new hash
+ # unless -D is used, in which case we remove the entry
+ #
+ # example entry: username:$apr1$gdehCd2T$ppFjRXlf1alPKSHqcBrjk0
+
+ for line in f:
+ (u, ph) = string.split(line.strip('\n'), ':')
+
+ if u == username:
+ if not delete_flag:
+ ph = hash(password, salt(8))
+ print('Updating password for user %s' % username)
+ contents.append((u, ph))
+
+ found = True
+ else:
+ contents.append((u, ph))
+
+if not found:
+ if delete_flag:
+ print('User %s not found' % username)
+ else:
+ print('Adding password for user %s' % username)
+ contents.append((username, hash(password, salt(8))))
+
+write_passwords(contents, file_path)
diff --git a/skel/gitano-admin/rules/defines.lace b/skel/gitano-admin/rules/defines.lace
index e72f598..95e729e 100644
--- a/skel/gitano-admin/rules/defines.lace
+++ b/skel/gitano-admin/rules/defines.lace
@@ -9,7 +9,8 @@ define if_asanother as_user ~.
# Self-related operations
define op_whoami operation whoami
define op_sshkey operation sshkey
-define op_self anyof op_whoami op_sshkey
+define op_passwd operation passwd
+define op_self anyof op_whoami op_sshkey op_passwd
# Admin-related operations
diff --git a/skel/gitano-admin/rules/selfchecks.lace b/skel/gitano-admin/rules/selfchecks.lace
index 300bb91..e30e557 100644
--- a/skel/gitano-admin/rules/selfchecks.lace
+++ b/skel/gitano-admin/rules/selfchecks.lace
@@ -3,3 +3,5 @@
allow "You may ask who you are" op_whoami
allow "You may manage your own ssh keys" op_sshkey
+
+allow "You may change your own password" op_passwd