Reasonable first half-usable version of scripts :-)
authorMagnus Hagander <magnus@hagander.net>
Wed, 17 Dec 2008 14:28:28 +0000 (15:28 +0100)
committerMagnus Hagander <magnus@hagander.net>
Wed, 17 Dec 2008 14:28:28 +0000 (15:28 +0100)
gitdump.py [new file with mode: 0644]
keysync.py [new file with mode: 0644]
pggit.py [new file with mode: 0755]
pggit.settings.sample [new file with mode: 0644]
schema.sql [new file with mode: 0644]

diff --git a/gitdump.py b/gitdump.py
new file mode 100644 (file)
index 0000000..ccc36e7
--- /dev/null
@@ -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 (file)
index 0000000..99ddaae
--- /dev/null
@@ -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 (executable)
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-<command> <argument>" or "git <command> <argument>"
+               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 (file)
index 0000000..bd31a0f
--- /dev/null
@@ -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 (file)
index 0000000..ba19ab6
--- /dev/null
@@ -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);
+