From bd8756e26e8df56b558bc123ea31fc4940959558 Mon Sep 17 00:00:00 2001 From: Magnus Hagander Date: Wed, 17 Dec 2008 15:28:28 +0100 Subject: [PATCH] Reasonable first half-usable version of scripts :-) --- gitdump.py | 73 +++++++++++++++++++++++++ keysync.py | 63 ++++++++++++++++++++++ pggit.py | 123 ++++++++++++++++++++++++++++++++++++++++++ pggit.settings.sample | 13 +++++ schema.sql | 25 +++++++++ 5 files changed, 297 insertions(+) create mode 100644 gitdump.py create mode 100644 keysync.py create mode 100755 pggit.py create mode 100644 pggit.settings.sample create mode 100644 schema.sql diff --git a/gitdump.py b/gitdump.py new file mode 100644 index 0000000..ccc36e7 --- /dev/null +++ b/gitdump.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python + +""" +Cron-job that dumps files required in the filesystem to make pggit work. + +This means: + +~/.ssh/authorized_keys + +FIXME: I believe it should also contain the web/anon publishing stuff +""" + +import sys +import os +import shutil +import psycopg2 +import ConfigParser +import urllib + +class AuthorizedKeysDumper: + def __init__(self, db, conf): + self.db = db + self.conf = conf + + def dump(self): + self.dumpkeys() + self.dumprepos() + + def dumpkeys(self): + # FIXME: use a trigger to indicate if *anything at all* has changed + curs = self.db.cursor() + curs.execute("SELECT userid,sshkey FROM git_users ORDER BY userid") + f = open("%s/.ssh/authorized_keys.tmp" % self.conf.get("paths", "githome"), "w") + for userid,sshkey in curs: + f.write("command=\"%s %s\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc %s\n" % (self.conf.get("paths", "pggit"), userid, sshkey)) + f.close() + os.chmod("%s/.ssh/authorized_keys.tmp" % self.conf.get("paths", "githome"), 0600) + os.rename("%s/.ssh/authorized_keys.tmp" % self.conf.get("paths", "githome"), "%s/.ssh/authorized_keys" % self.conf.get("paths", "githome")) + + def dumprepos(self): + # FIXME: use a trigger to indicate if *anything at all* has changed + curs = self.db.cursor() + curs.execute("SELECT name,anonymous,web FROM repositories WHERE approved ORDER BY name") + f = open("%s.tmp" % self.conf.get("paths", "gitweblist"), "w") + for name, anon, web in curs: + # Check if this repository exists at all + if not os.path.isdir("%s/repos/%s" % (self.conf.get("paths", "githome"), name)): + # Does not exist, let's initialize a new one + print "Initializing new git repository %s" % name + os.environ['GIT_DIR'] = "%s/repos/%s"% (self.conf.get("paths", "githome"), name) + os.system("git init --bare") + del os.environ['GIT_DIR'] + + # Check for publishing options here + if web: + f.write("%s\n" % (urllib.quote_plus(name))) + anonfile = "%s/repos/%s/git-daemon-export-ok" % (self.conf.get("paths", "githome"), name) + if anon: + if not os.path.isfile(anonfile): + open(anonfile, "w").close() + else: + if os.path.isfile(anonfile): + os.remove(anonfile) + f.close() + os.chmod("%s.tmp" % self.conf.get("paths", "gitweblist"), 0644) + os.rename("%s.tmp" % self.conf.get("paths", "gitweblist"), self.conf.get("paths", "gitweblist")) + +if __name__ == "__main__": + c = ConfigParser.ConfigParser() + c.read("pggit.settings") + db = psycopg2.connect(c.get('database','db')) + AuthorizedKeysDumper(db, c).dump() + diff --git a/keysync.py b/keysync.py new file mode 100644 index 0000000..99ddaae --- /dev/null +++ b/keysync.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python + +""" +Cron-job that synchronizes SSH public keys for all users that have them with the +wwwmaster system. + +""" + +import sys +import os +import psycopg2 +import ConfigParser + +class KeySynchronizer: + def __init__(self, db): + self.db = db + + def sync(self): + """ + Perform the synchronization. This is going to be rather inefficient - we just + load up the complete list of users in memory, and then write it to a local table. + + There's not likely (TM) to ever be a lot of data... + """ + masterpg = psycopg2.connect(c.get('database','masterdb')) + curs = self.db.cursor() + mcurs = masterpg.cursor() + + # Fetch last sync date, and see if anything has changed since + curs.execute("SELECT lastsync FROM key_last_sync LIMIT 1") + lastsync = curs.fetchone()[0] + + mcurs.execute("SELECT CURRENT_TIMESTAMP, CASE WHEN EXISTS (SELECT * FROM users_keys WHERE sshkey_last_update >= %s) THEN 1 ELSE 0 END", [lastsync]) + synctime, hasupd = mcurs.fetchone() + if hasupd == 0: + return # Nothing changed, just get out + + # Fetch a list of all keys on the master server + mcurs.execute("SELECT userid, sshkey FROM users_keys") + allkeys = mcurs.fetchall() + mcurs.close() + masterpg.close() + + # Load them into the local table + curs.execute("TRUNCATE TABLE git_users") + for row in allkeys: + curs.execute("INSERT INTO git_users (userid, sshkey) VALUES (%s,%s)", row) + + # If there ever turns out to be a bunch, better analyze + curs.execute("ANALYZE git_users") + + # Note the fact that we have synced (note that we use the timestamp value from the master, + # in case there is clock skew) + curs.execute("UPDATE key_last_sync SET lastsync=%s", [synctime]) + + self.db.commit() + +if __name__ == "__main__": + c = ConfigParser.ConfigParser() + c.read("pggit.settings") + db = psycopg2.connect(c.get('database','db')) + KeySynchronizer(db).sync() + diff --git a/pggit.py b/pggit.py new file mode 100755 index 0000000..4ad0932 --- /dev/null +++ b/pggit.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python + +""" +Hook to be called when attempting to access the git repository through +ssh. + +Verify permissions and pass control to git-shell if allowed. + +First commandline argument should contain the username to authenticate, +which is controlled by ~/.ssh/authorized_keys + +SVN_ORIGINAL_COMMAND contains the git command and argument, which is +controlled by the client side git command. +""" + +import sys +import os +import psycopg2 + +# MUST have trailing slash +REPOPREFIX="/home/gitlab/" + +ALLOWED_COMMANDS = ('git-upload-pack', 'git-receive-pack') +WRITE_COMMANDS = ('git-receive-pack') + +class Logger: + def __init__(self): + self.user = "Unknown" + + def log(self, message): + f = open("/home/gitlab/pggit.log","a") + f.write("(%s): %s" % (self.user, message)) + f.write("\n") + f.close() + + def setuser(self, user): + if user: + self.user = user + +class PgGit: + user = None + command = None + path = None + subpath = None + + def __init__(self): + self.logger = Logger() + pass + + def parse_commandline(self): + if len(sys.argv) != 2: + raise Exception("Can only be run with one commandline argument!") + self.user = sys.argv[1] + self.logger.setuser(self.user) + + def parse_command(self): + env = os.environ.get('SSH_ORIGINAL_COMMAND', None) + if not env: + raise Exception("No SSH_ORIGINAL_COMMAND present!") + + # env contains "git- " or "git " + command, args = env.split(None, 1) + if command == "git": + subcommand, args = args.split(None,1) + command = "git-%s" % subcommand + if not command in ALLOWED_COMMANDS: + raise Exception("Command '%s' not allowed" % command) + + self.command = command + if not args.startswith("'/"): + raise Exception("Expected git path to start with slash!") + + # FIXME: what about that single quote? Make sure it's there? + + # use os.path.normpath to make sure the user does not attempt to break out of the repository root + self.path = os.path.normpath(("%s%s" % (REPOPREFIX, args[2:].rstrip("'")))) + if not self.path.startswith(REPOPREFIX): + raise Exception("Escaping the root directory is of course not permitted") + if not os.path.exists(self.path): + raise Exception('git repository "%s" does not exist' % args) + self.subpath = self.path[len(REPOPREFIX):] + + def check_permissions(self): + writeperm = False + db = psycopg2.connect("dbname=gitlab host=/tmp/ user=mha") + curs = db.cursor() + curs.execute("SELECT write FROM repository_permissions INNER JOIN repositories ON repoid=repository WHERE userid=%s AND name=%s", + (self.user, self.subpath)) + try: + writeperm = curs.fetchone()[0] + except: + raise Exception("Permission denied on repository for user %s" % self.user) + + if self.command in WRITE_COMMANDS: + if not writeperm: + raise Exception("Write permission denied on repository for user %s" % self.user) + + + def run_command(self): + self.logger.log("Running \"git shell %s %s\"" % (self.command, "'%s'" % self.path)) + os.execvp('git', ['git', 'shell', '-c', "%s %s" % (self.command, "'%s'" % self.path) ]) + + def run(self): + try: + self.parse_commandline() + self.parse_command() + self.check_permissions() + self.run_command() + except Exception, e: + try: + self.logger.log(e) + except Exception, e: + # If we failed to log, try once more with a new logger, otherwise, + # just accept that we failed. + try: + Logger().log(e) + except: + pass + raise e + +if __name__ == "__main__": + PgGit().run() + diff --git a/pggit.settings.sample b/pggit.settings.sample new file mode 100644 index 0000000..bd31a0f --- /dev/null +++ b/pggit.settings.sample @@ -0,0 +1,13 @@ +[database] +db=dbname=pggit host=/tmp/ user=mha +;masterdb=hostname=wwwmaster.postgresql.org dbname=186_www user=auth_svc +masterdb=dbname=186_www host=/tmp/ user=mha + +[user] +user=pggit +group=pggit + +[paths] +githome=/home/gitlab +pggit=/opt/pgsql/pggit/pggit.py +gitweblist=/opt/pgsql/pggit/__temp__gitweb.list diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..ba19ab6 --- /dev/null +++ b/schema.sql @@ -0,0 +1,25 @@ +-- Assume a view 'git_users' that has the columns userid and sshkey + +CREATE TABLE repositories( + repoid SERIAL NOT NULL PRIMARY KEY, + name varchar(64) NOT NULL UNIQUE, + description text NOT NULL, + anonymous bool NOT NULL default 'f', + web bool NOT NULL default 'f', + approved bool NOT NULL DEFAULT 'f' +); + +CREATE TABLE repository_permissions ( + id SERIAL NOT NULL PRIMARY KEY, + repository int NOT NULL REFERENCES repositories(repoid), + userid text NOT NULL, -- intentionally not putting a foreign key here + level int NOT NULL DEFAULT 0, + CONSTRAINT levelcheck CHECK (level IN (0,1,2)) +); + +CREATE UNIQUE INDEX idx_repo_perm_rep_uid ON repository_permissions (repository, userid); + + +-- This is where we store the synchronized keys +CREATE TABLE git_users(userid text PRIMARY KEY, sshkey text); + -- 2.39.5