PageRenderTime 25ms CodeModel.GetById 21ms RepoModel.GetById 0ms app.codeStats 0ms

/hg-gateway

https://bitbucket.org/SlipperyFrob/hg-gateway
Ruby | 625 lines | 403 code | 65 blank | 157 comment | 60 complexity | ec5e6488531dbaab38cd0cb785d05735 MD5 | raw file
  1. #!/usr/bin/ruby
  2. # hg-gateway : Shared SSH access to Mercurial Repositories.
  3. # (c) Roshan James (2011)
  4. require 'csv'
  5. # SECTION 1
  6. #########################################################################
  7. # For most setups, only variables $hg_root_dir and $banner need be
  8. # edited.
  9. # Path relative to which all repo paths in the config file will be
  10. # interpreted. By default this is "~". If you have a repo called
  11. # "repo1" in your $hguser_config, it will be interpreted as
  12. # $hg_root_dir/repo1.
  13. $hg_root_dir = "~/hg"
  14. #Banner message for collaborators (optional)
  15. $banner = "Welcome to my server!"
  16. # Enable logging of incoming connections
  17. $log_enabled = true
  18. # Path to log file where you log user connections.
  19. $hguser_log = File.expand_path("~/.hg-gateway.log")
  20. # Path to the user configuration file. This file is created by
  21. # hg-gateway.
  22. #
  23. # This is a CSV file that determines which users have access to which
  24. # repos. Repos are specified as their dir paths - either absolute or
  25. # relative to $hg_root_dir. This file will be created when you use the
  26. # "add" subcommand for the first time.
  27. #
  28. # It is also human editable. The first value on a line is the username
  29. # and the subsequent values are repo names.
  30. #
  31. # For Example:
  32. # user1,repo1,repo2
  33. # user2,repo1
  34. # user3,~/secret_repos/repo,repo2
  35. $hguser_config = File.expand_path("~/.hg-gateway.config")
  36. # SECTION 2
  37. #########################################################################
  38. # For most setups, only variables in Section 1 need be edited. The
  39. # following may need to be changed for unusal machine configurations.
  40. # Path to authorized_keys file
  41. $authorized_keys = File.expand_path("~/.ssh/authorized_keys")
  42. # Path of this program
  43. $self_path = __FILE__
  44. # These are the recommended SSH options, so just let them be.
  45. $ssh_opts = "no-port-forwarding,no-X11-forwarding,no-agent-forwarding"
  46. # Are there commands that you would like the gateway to always
  47. # execute? Ex. logging, sourcing scripts etc. Put them here.
  48. #
  49. # This is particularly useful for GoDaddy servers that don't source
  50. # .bashrc (and hence dont set PATH) when sshing in.
  51. $owner_cmd = ""
  52. # $owner_cmd = "source ./bashrc"
  53. # Where is the main hg-gateway repository?
  54. # This is used for automatic updates via the "update" sub-command.
  55. $hg_gateway_central = "https://bitbucket.org/roshanjames/hg-gateway"
  56. ######################################################################
  57. # END OF USER CONFIGURATION. EDIT WHAT FOLLOWS ONLY IF YOU KNOW WHAT
  58. # YOU ARE DOING.
  59. ######################################################################
  60. # Credits
  61. #########
  62. # Abhishek Kulkarni - Many, many helpful suggestions
  63. # PAStheLoD - authorized_keys duplication fix, duplication checking.
  64. ######################################################################
  65. # Handle association of users to repos.
  66. $user2repos = Hash.new()
  67. def read_config(fn)
  68. if File.exist? fn
  69. CSV.foreach(fn){|row|
  70. user, repos = row[0], row[1..-1]
  71. add_to_user(user, repos)
  72. }
  73. end
  74. end
  75. def write_config(fn)
  76. CSV.open(fn,'w+') {|csv|
  77. $user2repos.each_key{|user|
  78. csv << [user] + $user2repos[user]
  79. }
  80. }
  81. end
  82. def user_exists?(user)
  83. return $user2repos[user]
  84. end
  85. def add_to_user(user, repos)
  86. $user2repos[user] = ($user2repos.fetch(user, []) + repos).uniq
  87. end
  88. def remove_from_user(user, repos)
  89. $user2repos[user] = $user2repos.fetch(user, []) - repos
  90. end
  91. #######################################################################
  92. # Helpers
  93. def cmd_cd_to_hg_root()
  94. cd_cmd = ""
  95. cd_cmd = "cd #{$hg_root_dir}; " if $hg_root_dir != nil
  96. return cd_cmd
  97. end
  98. def lines(ls)
  99. "\n\t#{ls.join("\n\t")}"
  100. end
  101. def repos_of(user)
  102. rs = $user2repos.fetch(user, [])
  103. rs = rs.collect{|r| r =~ /^\w+\:(.*)$/ ? $1 : r }
  104. rs.sort()
  105. end
  106. def ro_repos_of(user)
  107. rs = $user2repos.fetch(user, [])
  108. rs = (rs.collect{|r| $1 if r =~ /^ro\:(.*)$/}).compact()
  109. rs.sort()
  110. end
  111. def path_expand(repos)
  112. repos.collect{|name| File.expand_path(name, $hg_root_dir) }
  113. end
  114. def repo_summary(repos)
  115. style = "--template \"{date|isodate} <|> {rev}:{node|short} <|> {date|age} <|> {author|person} <|> {desc|firstline}\""
  116. table = []
  117. repos = repos.sort()
  118. repos.each{|name|
  119. dir = File.expand_path(name, $hg_root_dir)
  120. res = `hg -R \"#{dir}\" tip #{style}`
  121. if $?.exitstatus == 0
  122. parts = res.split("<|>").collect{|s| s.strip() }
  123. parts = parts.collect{|p| p.strip() }
  124. parts[2] = "(#{parts[2]})"
  125. parts[3] = parts[3].slice(0,17)
  126. parts[4] = "| " + parts[4]
  127. parts.insert(1, name)
  128. table.push parts
  129. end
  130. }
  131. # sort on the first col and then drop it.
  132. table.sort!.reverse!
  133. table = table.collect{|row| row.shift; row }
  134. print_table(table)
  135. end
  136. $repo_status_cache = Hash.new()
  137. def status_of(repo)
  138. repo = $1 if repo =~ /^ro\:(.*)$/
  139. dir = File.expand_path(repo, $hg_root_dir)
  140. s = $repo_status_cache[dir]
  141. if s
  142. s
  143. else
  144. s = `hg -R \"#{dir}\" tip --template \"{date|age}\" 2> /dev/null`
  145. s = "Warning: broken!!!" if $?.exitstatus != 0
  146. $repo_status_cache[dir] = s
  147. s
  148. end
  149. end
  150. def print_repos(repos)
  151. table = []
  152. repos.sort().each{|repo|
  153. st = status_of(repo)
  154. table.push [" " + repo, "| " + st]
  155. }
  156. print_table(table)
  157. end
  158. def follow(fn)
  159. if File.symlink?(fn)
  160. follow(File.expand_path(File.readlink(fn)))
  161. else
  162. fn
  163. end
  164. end
  165. def print_table(table)
  166. # Calculate widths
  167. widths = []
  168. table.each{|line|
  169. c = 0
  170. line.each{|col|
  171. widths[c] = (widths[c] && widths[c] > col.length) ? widths[c] : col.length
  172. c += 1
  173. }
  174. }
  175. # Indent the last column left.
  176. last = widths.pop()
  177. format = widths.collect{|n| "%#{n}s"}.join(" ")
  178. format += " %-#{last}s\n"
  179. # Print each line.
  180. table.each{|line|
  181. printf format, *line
  182. }
  183. $stdout.flush()
  184. end
  185. documentation = <<end
  186. hg-gateway v0.48 : Shared SSH access to Mercurial Repositories
  187. http://parametricity.net/b/hg-gateway
  188. (c) Roshan James (2011)
  189. Usage:
  190. hg-gateway <command> [args]
  191. Commands:
  192. adduser <user> [repos]- create a user and add repos (more below).
  193. add <user> <repo>+ - gives user access to repos.
  194. rm <user> <repo>+ - removes user access to repos.
  195. ls [user] - list permissions by username.
  196. lsr [repo] - list permissions by repo name.
  197. create <repo> [users] - create a remote repo and add users.
  198. summary [user] - summary of known repositories [for a user].
  199. update [-f] - upgrade hg-gateway (if installed as a repo).
  200. help - this help.
  201. Commands configurable in authorized_keys:
  202. owner - gives account owner's public key full SSH access.
  203. login <user> - gives restricted hg access to user.
  204. Getting started:
  205. ================
  206. (1) Adding users: To create a hg-gateway user, you need their public
  207. key. Say this is "user1.pub". The following command creates a user
  208. called "user1":
  209. $ cat user1.pub | hg-gateway adduser user1
  210. This command will create an entry in your authorized_keys with
  211. command="hg-gateway login user1" qualifying the user's SSH key. Thus
  212. hg-gateway knows which incoming connections belong to which users.
  213. (2) Adding yourself (the SSH account owner): It is useful (though not
  214. required) to add command="hg-gateway owner" next to your own SSH key
  215. in authorized_keys. (More: http://parametricity.net/b/hg-gateway)
  216. (3) Deleting/Removing Users: If you ever want to completely remove a
  217. user from hg-gateway you must remove their SSH key from
  218. authorized_keys and remove their permissions from $hguser_config. Each
  219. user has a line in that file, just delete that line. hg-gateway does
  220. not expose commands to delete these because destructive operations are
  221. best done with some human supervision.
  222. end
  223. #######################################################################
  224. # A big case statement to dispatch sub-commands:
  225. #
  226. # Adding a new user using "adduser <username>" will read the users
  227. # public key from stdin and create an entry in $authorized_keys for
  228. # the user. The new entry is strictly appended to $authorized_keys and
  229. # repeated calls to this script will create multiple entries.
  230. #
  231. # This script is designed to be invoked from your local machines as
  232. # follows:
  233. # $ cat key.pub | ssh shared@server.com hg-gateway adduser user1
  234. case ARGV[0]
  235. when "adduser"
  236. user = ARGV[1]
  237. repos = ARGV[2..-1]
  238. key = $stdin.gets().strip()
  239. # If user exists, say so.
  240. read_config($hguser_config)
  241. if user_exists?(user)
  242. puts "User #{user} already exists, adding additional key."
  243. end
  244. # Check the $authorized_keys file for duplicate key.
  245. f = File.new($authorized_keys, "r+")
  246. lines = f.readlines().collect{|l|
  247. if l.include?(key.chomp.split[1])
  248. puts "Not adding key for user #{user}. Key already in the authorized_keys file:\n#{l}"
  249. exit(1)
  250. end
  251. l.chomp()
  252. }
  253. lines.push "command=\"#{$self_path} login #{user}\",#{$ssh_opts} #{key}\n"
  254. f.seek(0) # Explicitely seek to 0 offset.
  255. f.puts lines.join("\n")
  256. f.close()
  257. # Create an entry for this user.
  258. add_to_user(user, repos)
  259. write_config($hguser_config)
  260. puts "Key added for hg-gateway user \"#{user}\"."
  261. puts "Add more repos for \"#{user}\" using \"hg-gateway add #{user} <repo names>\"."
  262. puts "User #{user} currently has access to #{repos_of(user).length} repos."
  263. # Add and remove repos accessible by users.
  264. when "add", "rm"
  265. user = ARGV[1]
  266. repos = ARGV[2..-1]
  267. read_config($hguser_config)
  268. if not user_exists?(user)
  269. puts "User #{user} does not exist."
  270. puts "Use ls/lsr commands to list known repos and users."
  271. exit(1)
  272. end
  273. orig = $user2repos.fetch(user, [])
  274. if ARGV[0] == "add"
  275. repos = repos.delete_if{|repo|
  276. repo = $1 if repo =~ /^ro\:(.+)$/
  277. dir = File.expand_path(repo, $hg_root_dir)
  278. if not File.exists?(dir + "/.hg")
  279. puts "Error: No repo #{repo} (at #{dir})."
  280. true
  281. else
  282. false
  283. end
  284. }
  285. add_to_user(user, repos)
  286. puts "User #{user}:"
  287. $user2repos[user].sort().each{|r|
  288. flag = " "
  289. flag = "(added) " if repos.include? r
  290. puts " #{flag}#{r}"
  291. }
  292. else
  293. remove_from_user(user, repos)
  294. puts "User #{user}:"
  295. orig.sort().each{|r|
  296. flag = " "
  297. flag = "(removed) " if repos.include? r
  298. puts " #{flag}#{r}"
  299. }
  300. end
  301. write_config($hguser_config)
  302. # List by username
  303. when "ls"
  304. read_config($hguser_config)
  305. # Show
  306. if ARGV[1] != nil
  307. user = ARGV[1]
  308. if user_exists?(user)
  309. puts "User #{user}:"
  310. print_repos($user2repos.fetch(user, []))
  311. else
  312. puts "User #{user} does not exist."
  313. end
  314. else
  315. $user2repos.keys.sort().each{|user|
  316. puts "User #{user}:"
  317. print_repos($user2repos.fetch(user, []))
  318. }
  319. end
  320. # List by repository name
  321. when "lsr"
  322. read_config($hguser_config)
  323. # Construct mapping "repo -> [user]"
  324. repo2users = Hash.new()
  325. $user2repos.each_key{|user|
  326. $user2repos[user].each{|repo|
  327. ls = repo2users.fetch(repo, [])
  328. ls.push user
  329. repo2users[repo] = ls.uniq
  330. }
  331. }
  332. # Show
  333. if ARGV[1] != nil
  334. repo = ARGV[1]
  335. st = status_of(repo)
  336. puts "Repo #{repo}: (#{st})#{lines(repo2users.fetch(repo, []))}"
  337. else
  338. repo2users.keys().sort().each{|repo|
  339. st = status_of(repo)
  340. puts "Repo #{repo}: (#{st})#{lines(repo2users[repo])}"
  341. }
  342. end
  343. # Create a repo and add users.
  344. when "create"
  345. repo = ARGV[1]
  346. users = ARGV[2..-1]
  347. if repo == nil
  348. puts "Please specify a repo name!"
  349. exit(1)
  350. end
  351. path = File.expand_path(repo, $hg_root_dir)
  352. if File.exists?(path)
  353. puts "\"#{repo}\" already exists."
  354. exit(1)
  355. end
  356. system("hg init #{path}")
  357. puts "Created repo #{repo} at #{path}"
  358. if users.length > 0
  359. read_config($hguser_config)
  360. users.each{|user|
  361. if user_exists?(user)
  362. puts "Adding #{user} to #{repo}."
  363. add_to_user(user, [repo])
  364. else
  365. puts "User #{user} does not exist."
  366. end
  367. }
  368. write_config($hguser_config)
  369. end
  370. # Display a summary of repos as visible by some user. Note that
  371. # hg-gateway does not ever have a full list of all repos in your
  372. # system - so as the owner you get not such summary listing for
  373. # yourself.
  374. when "summary"
  375. user = ARGV[1]
  376. if user != nil
  377. read_config($hguser_config)
  378. if not user_exists?(user)
  379. puts "User #{user} does not exist."
  380. else
  381. rs = repos_of(user)
  382. puts "Summary for user #{user}:"
  383. repo_summary(rs)
  384. end
  385. else
  386. Dir.chdir(File.expand_path($hg_root_dir))
  387. puts "Recursively enumerating all repos under #{$hg_root_dir}:"
  388. $stdout.flush()
  389. repos = Dir["**/.hg"].collect{|dir|
  390. dir = File.dirname(dir)
  391. }
  392. repo_summary(repos)
  393. end
  394. # This subcommand is added to the authorized_keys as part of a
  395. # "command=" clause by the "adduser" subcommand. This restricts the
  396. # particular key to have restricted hg access.
  397. #
  398. # All of the code under this sub-command should be reviewed carefully
  399. # since this is the only code in the whole system that handles
  400. # users. When a commenction is made using a particular SSH key, this
  401. # code is executed. If this code has a bug then then that user maybe
  402. # able to gain full access to your system.
  403. #
  404. # A bug in any other part of hg-gateway, maybe cause problems of
  405. # various sorts, but will not lead to a security breach easily.
  406. #
  407. # This code should do the following:
  408. #
  409. # (1) Log the incoming connectoin, if logging is enabled.
  410. #
  411. # (2) Display a banner.
  412. #
  413. # (3) If the incoming command is hg serve, then do access checks and
  414. # then serve. If the repo has been given read-only access, then serve
  415. # with the appropriate hg hook.
  416. #
  417. # (4) If there is no incoming SSH command, then give the user a
  418. # summary of their repos.
  419. #
  420. # (5) Reject all other incoming SSH commands.
  421. when "login"
  422. user = ARGV[1]
  423. # logging
  424. if $log_enabled
  425. f = File.new($hguser_log, "a+")
  426. if ENV["SSH_CONNECTION"] =~ /^([\d\.]+\s+\d+)\s+/
  427. f.puts "#{Time.now} : #{$1} : #{user} : #{ENV["SSH_ORIGINAL_COMMAND"]}"
  428. end
  429. f.close()
  430. end
  431. # Banner
  432. if $banner
  433. $stderr.puts $banner
  434. $stderr.flush()
  435. end
  436. read_config($hguser_config)
  437. if not user_exists?(user)
  438. puts "No repositories for #{user}"
  439. else
  440. rs = repos_of(user)
  441. cmd = ENV["SSH_ORIGINAL_COMMAND"]
  442. # No cmd. Display summary.
  443. if cmd == nil || cmd == ""
  444. puts "Your SSH access is restricted by hg-gateway. "
  445. puts "Summary of repos you have access to:"
  446. repo_summary(rs)
  447. # cmd = hg serve, then serve if user has access.
  448. elsif cmd =~ /^hg\s+\-R\s+(\S+)\s+serve\s+\-\-stdio$/
  449. req = $1
  450. repo = File.expand_path(req, $hg_root_dir)
  451. rs = path_expand(rs)
  452. if rs.include?(repo)
  453. ro = path_expand(ro_repos_of(user))
  454. no_push = ""
  455. # if read-only access then enable the hook to enforce it.
  456. if ro.include?(repo)
  457. no_push = "--config hooks.pretxnchangegroup=\"false\""
  458. $stderr.puts "hg-gateway: you do not have \"hg push\" permissions to repository \"#{req}\""
  459. $stderr.flush()
  460. end
  461. cmd = "#{cmd_cd_to_hg_root()}hg #{no_push} -R #{repo} serve --stdio"
  462. exec(cmd)
  463. # if no access to repo, then deny.
  464. else
  465. $stderr.puts "hg-gateway: you do not have access to repository: #{req}"
  466. $stderr.flush()
  467. end
  468. # Deny any other SSH command.
  469. else
  470. $stderr.puts "hg-gateway: disallowed command: #{cmd}"
  471. $stderr.flush()
  472. end
  473. end
  474. # Put command="hg-gateway owner" next your own public key. This will
  475. # let you SSH as usual but will also "cd" to the $hg_root_dir whenever
  476. # you are running an hg operation remotley. This way you and your
  477. # collaborators can have the same pull/push path in your hgrc
  478. # files. Without this when you pull/push remotely you will have to
  479. # specify the actual path in your server where your repos reside.
  480. #
  481. # Note that the owner has no user name, unlike in the case of users
  482. # configured via the "login" sub-command. Connections comming in via
  483. # your SSH key have no access restrictions imposed by hg-gateway. It
  484. # is your SSH account and its meaningless for hg-gateway to restrict
  485. # access. One implication of this is that you don't have a command to
  486. # list all the repos in the system - since hg-gateway hasn't been
  487. # configured with any such list. If you need such things, SSH into
  488. # your box.
  489. when "owner"
  490. cmd = ENV["SSH_ORIGINAL_COMMAND"]
  491. # puts "Original Command: #{cmd}"
  492. # $stdout.flush()
  493. if cmd == "" || cmd == nil
  494. exec("/bin/bash -l")
  495. elsif cmd =~ /^hg \-R\s+(.*)\s*serve \-\-stdio/
  496. exec("#{$owner_cmd} #{cmd_cd_to_hg_root()} #{cmd}")
  497. elsif
  498. exec("#{$owner_cmd} #{cmd}")
  499. end
  500. # [Deprecated].
  501. # Use this command if you want to use hg-gateway to validate push
  502. # permissions to the pwd for the username in $HG_GATEWAY_USER. You
  503. # would add this to the relevant hgrc file as follows to accomplish
  504. # this:
  505. #
  506. #[hooks]
  507. #pretxnchnagegrpup=hg-gateway check_push
  508. when "check_push"
  509. user = ENV["HG_GATEWAY_USER"]
  510. exit (0) if user == nil
  511. read_config($hguser_config)
  512. ro = path_expand(ro_repos_of(user))
  513. if ro.include?(Dir.pwd)
  514. $stderr.puts "You are not allowed to push to this repo!!"
  515. exit(1)
  516. end
  517. # Allow hg-gateway to update itself, by "hg fetch"-ing a new
  518. # copy. Since we are doing an hg-fetch, your local edits and
  519. # customisation should be automatically merged, in the common case.
  520. #
  521. # For this to work, your current hg-gateway installation must have
  522. # been created via "hg clone" from the original hg-gateway repository
  523. # in the sky.
  524. when "update"
  525. # Before we decide to update, check to see if hg-gateway is indeed
  526. # in a repo.
  527. real = follow(__FILE__)
  528. dir = File.dirname(real)
  529. is_repo = File.exists?(dir + "/.hg")
  530. if not is_repo
  531. puts "Cannot Upgrade: Your hg-gateway \"#{real}\" is not in a repo."
  532. puts "Upgrading works by \"hg fetch\"-ing changes."
  533. exit(1)
  534. end
  535. s = "y"
  536. if ARGV[1] != "-f"
  537. # Now prompt.
  538. puts "hg-gateway will now try to update itself by doing an \"hg fetch\""
  539. puts "from #{$hg_gateway_central}."
  540. print "Do you want to continue? (y/n) "
  541. $stdout.flush()
  542. s = $stdin.gets()
  543. end
  544. # In the common case the merge should succeed and everything should
  545. # work. If for any reason merge fails, you can manually merge or
  546. # just revert the changes.
  547. if s.chomp() == "y"
  548. enable_fetch = "--config extensions.fetch=\"\""
  549. cmd = "hg -R \"#{dir}\" #{enable_fetch} fetch #{$hg_gateway_central}"
  550. system(cmd)
  551. end
  552. when "help"
  553. puts documentation
  554. else
  555. puts "Unknown command: #{ARGV.join(" ")}"
  556. puts documentation
  557. end