/hg-gateway
Ruby | 625 lines | 403 code | 65 blank | 157 comment | 60 complexity | ec5e6488531dbaab38cd0cb785d05735 MD5 | raw file
- #!/usr/bin/ruby
- # hg-gateway : Shared SSH access to Mercurial Repositories.
- # (c) Roshan James (2011)
- require 'csv'
- # SECTION 1
- #########################################################################
- # For most setups, only variables $hg_root_dir and $banner need be
- # edited.
- # Path relative to which all repo paths in the config file will be
- # interpreted. By default this is "~". If you have a repo called
- # "repo1" in your $hguser_config, it will be interpreted as
- # $hg_root_dir/repo1.
- $hg_root_dir = "~/hg"
- #Banner message for collaborators (optional)
- $banner = "Welcome to my server!"
- # Enable logging of incoming connections
- $log_enabled = true
- # Path to log file where you log user connections.
- $hguser_log = File.expand_path("~/.hg-gateway.log")
- # Path to the user configuration file. This file is created by
- # hg-gateway.
- #
- # This is a CSV file that determines which users have access to which
- # repos. Repos are specified as their dir paths - either absolute or
- # relative to $hg_root_dir. This file will be created when you use the
- # "add" subcommand for the first time.
- #
- # It is also human editable. The first value on a line is the username
- # and the subsequent values are repo names.
- #
- # For Example:
- # user1,repo1,repo2
- # user2,repo1
- # user3,~/secret_repos/repo,repo2
- $hguser_config = File.expand_path("~/.hg-gateway.config")
- # SECTION 2
- #########################################################################
- # For most setups, only variables in Section 1 need be edited. The
- # following may need to be changed for unusal machine configurations.
- # Path to authorized_keys file
- $authorized_keys = File.expand_path("~/.ssh/authorized_keys")
- # Path of this program
- $self_path = __FILE__
- # These are the recommended SSH options, so just let them be.
- $ssh_opts = "no-port-forwarding,no-X11-forwarding,no-agent-forwarding"
- # Are there commands that you would like the gateway to always
- # execute? Ex. logging, sourcing scripts etc. Put them here.
- #
- # This is particularly useful for GoDaddy servers that don't source
- # .bashrc (and hence dont set PATH) when sshing in.
- $owner_cmd = ""
- # $owner_cmd = "source ./bashrc"
- # Where is the main hg-gateway repository?
- # This is used for automatic updates via the "update" sub-command.
- $hg_gateway_central = "https://bitbucket.org/roshanjames/hg-gateway"
- ######################################################################
- # END OF USER CONFIGURATION. EDIT WHAT FOLLOWS ONLY IF YOU KNOW WHAT
- # YOU ARE DOING.
- ######################################################################
- # Credits
- #########
- # Abhishek Kulkarni - Many, many helpful suggestions
- # PAStheLoD - authorized_keys duplication fix, duplication checking.
- ######################################################################
- # Handle association of users to repos.
- $user2repos = Hash.new()
- def read_config(fn)
- if File.exist? fn
- CSV.foreach(fn){|row|
- user, repos = row[0], row[1..-1]
- add_to_user(user, repos)
- }
- end
- end
- def write_config(fn)
- CSV.open(fn,'w+') {|csv|
- $user2repos.each_key{|user|
- csv << [user] + $user2repos[user]
- }
- }
- end
- def user_exists?(user)
- return $user2repos[user]
- end
- def add_to_user(user, repos)
- $user2repos[user] = ($user2repos.fetch(user, []) + repos).uniq
- end
- def remove_from_user(user, repos)
- $user2repos[user] = $user2repos.fetch(user, []) - repos
- end
- #######################################################################
- # Helpers
- def cmd_cd_to_hg_root()
- cd_cmd = ""
- cd_cmd = "cd #{$hg_root_dir}; " if $hg_root_dir != nil
- return cd_cmd
- end
- def lines(ls)
- "\n\t#{ls.join("\n\t")}"
- end
- def repos_of(user)
- rs = $user2repos.fetch(user, [])
- rs = rs.collect{|r| r =~ /^\w+\:(.*)$/ ? $1 : r }
- rs.sort()
- end
- def ro_repos_of(user)
- rs = $user2repos.fetch(user, [])
- rs = (rs.collect{|r| $1 if r =~ /^ro\:(.*)$/}).compact()
- rs.sort()
- end
- def path_expand(repos)
- repos.collect{|name| File.expand_path(name, $hg_root_dir) }
- end
- def repo_summary(repos)
- style = "--template \"{date|isodate} <|> {rev}:{node|short} <|> {date|age} <|> {author|person} <|> {desc|firstline}\""
- table = []
- repos = repos.sort()
- repos.each{|name|
- dir = File.expand_path(name, $hg_root_dir)
- res = `hg -R \"#{dir}\" tip #{style}`
- if $?.exitstatus == 0
- parts = res.split("<|>").collect{|s| s.strip() }
- parts = parts.collect{|p| p.strip() }
- parts[2] = "(#{parts[2]})"
- parts[3] = parts[3].slice(0,17)
- parts[4] = "| " + parts[4]
- parts.insert(1, name)
- table.push parts
- end
- }
- # sort on the first col and then drop it.
- table.sort!.reverse!
- table = table.collect{|row| row.shift; row }
- print_table(table)
- end
- $repo_status_cache = Hash.new()
- def status_of(repo)
- repo = $1 if repo =~ /^ro\:(.*)$/
- dir = File.expand_path(repo, $hg_root_dir)
- s = $repo_status_cache[dir]
- if s
- s
- else
- s = `hg -R \"#{dir}\" tip --template \"{date|age}\" 2> /dev/null`
- s = "Warning: broken!!!" if $?.exitstatus != 0
- $repo_status_cache[dir] = s
- s
- end
- end
- def print_repos(repos)
- table = []
- repos.sort().each{|repo|
- st = status_of(repo)
- table.push [" " + repo, "| " + st]
- }
- print_table(table)
- end
- def follow(fn)
- if File.symlink?(fn)
- follow(File.expand_path(File.readlink(fn)))
- else
- fn
- end
- end
- def print_table(table)
- # Calculate widths
- widths = []
- table.each{|line|
- c = 0
- line.each{|col|
- widths[c] = (widths[c] && widths[c] > col.length) ? widths[c] : col.length
- c += 1
- }
- }
- # Indent the last column left.
- last = widths.pop()
- format = widths.collect{|n| "%#{n}s"}.join(" ")
- format += " %-#{last}s\n"
- # Print each line.
- table.each{|line|
- printf format, *line
- }
- $stdout.flush()
- end
- documentation = <<end
- hg-gateway v0.48 : Shared SSH access to Mercurial Repositories
- http://parametricity.net/b/hg-gateway
- (c) Roshan James (2011)
- Usage:
- hg-gateway <command> [args]
- Commands:
- adduser <user> [repos]- create a user and add repos (more below).
- add <user> <repo>+ - gives user access to repos.
- rm <user> <repo>+ - removes user access to repos.
- ls [user] - list permissions by username.
- lsr [repo] - list permissions by repo name.
- create <repo> [users] - create a remote repo and add users.
- summary [user] - summary of known repositories [for a user].
- update [-f] - upgrade hg-gateway (if installed as a repo).
- help - this help.
- Commands configurable in authorized_keys:
- owner - gives account owner's public key full SSH access.
- login <user> - gives restricted hg access to user.
- Getting started:
- ================
- (1) Adding users: To create a hg-gateway user, you need their public
- key. Say this is "user1.pub". The following command creates a user
- called "user1":
- $ cat user1.pub | hg-gateway adduser user1
- This command will create an entry in your authorized_keys with
- command="hg-gateway login user1" qualifying the user's SSH key. Thus
- hg-gateway knows which incoming connections belong to which users.
- (2) Adding yourself (the SSH account owner): It is useful (though not
- required) to add command="hg-gateway owner" next to your own SSH key
- in authorized_keys. (More: http://parametricity.net/b/hg-gateway)
- (3) Deleting/Removing Users: If you ever want to completely remove a
- user from hg-gateway you must remove their SSH key from
- authorized_keys and remove their permissions from $hguser_config. Each
- user has a line in that file, just delete that line. hg-gateway does
- not expose commands to delete these because destructive operations are
- best done with some human supervision.
- end
- #######################################################################
- # A big case statement to dispatch sub-commands:
- #
- # Adding a new user using "adduser <username>" will read the users
- # public key from stdin and create an entry in $authorized_keys for
- # the user. The new entry is strictly appended to $authorized_keys and
- # repeated calls to this script will create multiple entries.
- #
- # This script is designed to be invoked from your local machines as
- # follows:
- # $ cat key.pub | ssh shared@server.com hg-gateway adduser user1
- case ARGV[0]
- when "adduser"
- user = ARGV[1]
- repos = ARGV[2..-1]
- key = $stdin.gets().strip()
- # If user exists, say so.
- read_config($hguser_config)
- if user_exists?(user)
- puts "User #{user} already exists, adding additional key."
- end
- # Check the $authorized_keys file for duplicate key.
- f = File.new($authorized_keys, "r+")
- lines = f.readlines().collect{|l|
- if l.include?(key.chomp.split[1])
- puts "Not adding key for user #{user}. Key already in the authorized_keys file:\n#{l}"
- exit(1)
- end
- l.chomp()
- }
- lines.push "command=\"#{$self_path} login #{user}\",#{$ssh_opts} #{key}\n"
- f.seek(0) # Explicitely seek to 0 offset.
- f.puts lines.join("\n")
- f.close()
- # Create an entry for this user.
- add_to_user(user, repos)
- write_config($hguser_config)
- puts "Key added for hg-gateway user \"#{user}\"."
- puts "Add more repos for \"#{user}\" using \"hg-gateway add #{user} <repo names>\"."
- puts "User #{user} currently has access to #{repos_of(user).length} repos."
- # Add and remove repos accessible by users.
- when "add", "rm"
- user = ARGV[1]
- repos = ARGV[2..-1]
- read_config($hguser_config)
- if not user_exists?(user)
- puts "User #{user} does not exist."
- puts "Use ls/lsr commands to list known repos and users."
- exit(1)
- end
- orig = $user2repos.fetch(user, [])
- if ARGV[0] == "add"
- repos = repos.delete_if{|repo|
- repo = $1 if repo =~ /^ro\:(.+)$/
- dir = File.expand_path(repo, $hg_root_dir)
- if not File.exists?(dir + "/.hg")
- puts "Error: No repo #{repo} (at #{dir})."
- true
- else
- false
- end
- }
- add_to_user(user, repos)
- puts "User #{user}:"
- $user2repos[user].sort().each{|r|
- flag = " "
- flag = "(added) " if repos.include? r
- puts " #{flag}#{r}"
- }
- else
- remove_from_user(user, repos)
- puts "User #{user}:"
- orig.sort().each{|r|
- flag = " "
- flag = "(removed) " if repos.include? r
- puts " #{flag}#{r}"
- }
- end
- write_config($hguser_config)
- # List by username
- when "ls"
- read_config($hguser_config)
- # Show
- if ARGV[1] != nil
- user = ARGV[1]
- if user_exists?(user)
- puts "User #{user}:"
- print_repos($user2repos.fetch(user, []))
- else
- puts "User #{user} does not exist."
- end
- else
- $user2repos.keys.sort().each{|user|
- puts "User #{user}:"
- print_repos($user2repos.fetch(user, []))
- }
- end
- # List by repository name
- when "lsr"
- read_config($hguser_config)
- # Construct mapping "repo -> [user]"
- repo2users = Hash.new()
- $user2repos.each_key{|user|
- $user2repos[user].each{|repo|
- ls = repo2users.fetch(repo, [])
- ls.push user
- repo2users[repo] = ls.uniq
- }
- }
- # Show
- if ARGV[1] != nil
- repo = ARGV[1]
- st = status_of(repo)
- puts "Repo #{repo}: (#{st})#{lines(repo2users.fetch(repo, []))}"
- else
- repo2users.keys().sort().each{|repo|
- st = status_of(repo)
- puts "Repo #{repo}: (#{st})#{lines(repo2users[repo])}"
- }
- end
- # Create a repo and add users.
- when "create"
- repo = ARGV[1]
- users = ARGV[2..-1]
- if repo == nil
- puts "Please specify a repo name!"
- exit(1)
- end
- path = File.expand_path(repo, $hg_root_dir)
- if File.exists?(path)
- puts "\"#{repo}\" already exists."
- exit(1)
- end
- system("hg init #{path}")
- puts "Created repo #{repo} at #{path}"
-
- if users.length > 0
- read_config($hguser_config)
- users.each{|user|
- if user_exists?(user)
- puts "Adding #{user} to #{repo}."
- add_to_user(user, [repo])
- else
- puts "User #{user} does not exist."
- end
- }
- write_config($hguser_config)
- end
- # Display a summary of repos as visible by some user. Note that
- # hg-gateway does not ever have a full list of all repos in your
- # system - so as the owner you get not such summary listing for
- # yourself.
- when "summary"
- user = ARGV[1]
- if user != nil
- read_config($hguser_config)
- if not user_exists?(user)
- puts "User #{user} does not exist."
- else
- rs = repos_of(user)
- puts "Summary for user #{user}:"
- repo_summary(rs)
- end
- else
- Dir.chdir(File.expand_path($hg_root_dir))
- puts "Recursively enumerating all repos under #{$hg_root_dir}:"
- $stdout.flush()
- repos = Dir["**/.hg"].collect{|dir|
- dir = File.dirname(dir)
- }
- repo_summary(repos)
- end
- # This subcommand is added to the authorized_keys as part of a
- # "command=" clause by the "adduser" subcommand. This restricts the
- # particular key to have restricted hg access.
- #
- # All of the code under this sub-command should be reviewed carefully
- # since this is the only code in the whole system that handles
- # users. When a commenction is made using a particular SSH key, this
- # code is executed. If this code has a bug then then that user maybe
- # able to gain full access to your system.
- #
- # A bug in any other part of hg-gateway, maybe cause problems of
- # various sorts, but will not lead to a security breach easily.
- #
- # This code should do the following:
- #
- # (1) Log the incoming connectoin, if logging is enabled.
- #
- # (2) Display a banner.
- #
- # (3) If the incoming command is hg serve, then do access checks and
- # then serve. If the repo has been given read-only access, then serve
- # with the appropriate hg hook.
- #
- # (4) If there is no incoming SSH command, then give the user a
- # summary of their repos.
- #
- # (5) Reject all other incoming SSH commands.
- when "login"
- user = ARGV[1]
- # logging
- if $log_enabled
- f = File.new($hguser_log, "a+")
- if ENV["SSH_CONNECTION"] =~ /^([\d\.]+\s+\d+)\s+/
- f.puts "#{Time.now} : #{$1} : #{user} : #{ENV["SSH_ORIGINAL_COMMAND"]}"
- end
- f.close()
- end
- # Banner
- if $banner
- $stderr.puts $banner
- $stderr.flush()
- end
- read_config($hguser_config)
- if not user_exists?(user)
- puts "No repositories for #{user}"
- else
- rs = repos_of(user)
- cmd = ENV["SSH_ORIGINAL_COMMAND"]
- # No cmd. Display summary.
- if cmd == nil || cmd == ""
- puts "Your SSH access is restricted by hg-gateway. "
- puts "Summary of repos you have access to:"
- repo_summary(rs)
- # cmd = hg serve, then serve if user has access.
- elsif cmd =~ /^hg\s+\-R\s+(\S+)\s+serve\s+\-\-stdio$/
- req = $1
- repo = File.expand_path(req, $hg_root_dir)
- rs = path_expand(rs)
- if rs.include?(repo)
- ro = path_expand(ro_repos_of(user))
- no_push = ""
- # if read-only access then enable the hook to enforce it.
- if ro.include?(repo)
- no_push = "--config hooks.pretxnchangegroup=\"false\""
- $stderr.puts "hg-gateway: you do not have \"hg push\" permissions to repository \"#{req}\""
- $stderr.flush()
- end
- cmd = "#{cmd_cd_to_hg_root()}hg #{no_push} -R #{repo} serve --stdio"
- exec(cmd)
- # if no access to repo, then deny.
- else
- $stderr.puts "hg-gateway: you do not have access to repository: #{req}"
- $stderr.flush()
- end
- # Deny any other SSH command.
- else
- $stderr.puts "hg-gateway: disallowed command: #{cmd}"
- $stderr.flush()
- end
- end
- # Put command="hg-gateway owner" next your own public key. This will
- # let you SSH as usual but will also "cd" to the $hg_root_dir whenever
- # you are running an hg operation remotley. This way you and your
- # collaborators can have the same pull/push path in your hgrc
- # files. Without this when you pull/push remotely you will have to
- # specify the actual path in your server where your repos reside.
- #
- # Note that the owner has no user name, unlike in the case of users
- # configured via the "login" sub-command. Connections comming in via
- # your SSH key have no access restrictions imposed by hg-gateway. It
- # is your SSH account and its meaningless for hg-gateway to restrict
- # access. One implication of this is that you don't have a command to
- # list all the repos in the system - since hg-gateway hasn't been
- # configured with any such list. If you need such things, SSH into
- # your box.
- when "owner"
- cmd = ENV["SSH_ORIGINAL_COMMAND"]
- # puts "Original Command: #{cmd}"
- # $stdout.flush()
- if cmd == "" || cmd == nil
- exec("/bin/bash -l")
- elsif cmd =~ /^hg \-R\s+(.*)\s*serve \-\-stdio/
- exec("#{$owner_cmd} #{cmd_cd_to_hg_root()} #{cmd}")
- elsif
- exec("#{$owner_cmd} #{cmd}")
- end
- # [Deprecated].
- # Use this command if you want to use hg-gateway to validate push
- # permissions to the pwd for the username in $HG_GATEWAY_USER. You
- # would add this to the relevant hgrc file as follows to accomplish
- # this:
- #
- #[hooks]
- #pretxnchnagegrpup=hg-gateway check_push
- when "check_push"
- user = ENV["HG_GATEWAY_USER"]
- exit (0) if user == nil
- read_config($hguser_config)
- ro = path_expand(ro_repos_of(user))
- if ro.include?(Dir.pwd)
- $stderr.puts "You are not allowed to push to this repo!!"
- exit(1)
- end
- # Allow hg-gateway to update itself, by "hg fetch"-ing a new
- # copy. Since we are doing an hg-fetch, your local edits and
- # customisation should be automatically merged, in the common case.
- #
- # For this to work, your current hg-gateway installation must have
- # been created via "hg clone" from the original hg-gateway repository
- # in the sky.
- when "update"
- # Before we decide to update, check to see if hg-gateway is indeed
- # in a repo.
- real = follow(__FILE__)
- dir = File.dirname(real)
- is_repo = File.exists?(dir + "/.hg")
- if not is_repo
- puts "Cannot Upgrade: Your hg-gateway \"#{real}\" is not in a repo."
- puts "Upgrading works by \"hg fetch\"-ing changes."
- exit(1)
- end
- s = "y"
- if ARGV[1] != "-f"
- # Now prompt.
- puts "hg-gateway will now try to update itself by doing an \"hg fetch\""
- puts "from #{$hg_gateway_central}."
- print "Do you want to continue? (y/n) "
- $stdout.flush()
- s = $stdin.gets()
- end
- # In the common case the merge should succeed and everything should
- # work. If for any reason merge fails, you can manually merge or
- # just revert the changes.
- if s.chomp() == "y"
- enable_fetch = "--config extensions.fetch=\"\""
- cmd = "hg -R \"#{dir}\" #{enable_fetch} fetch #{$hg_gateway_central}"
- system(cmd)
- end
-
- when "help"
- puts documentation
- else
- puts "Unknown command: #{ARGV.join(" ")}"
- puts documentation
- end