smart_proxy_container_gateway 1.0.3 → 1.0.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/smart_proxy_container_gateway/container_gateway_api.rb +86 -30
- data/lib/smart_proxy_container_gateway/container_gateway_main.rb +95 -23
- data/lib/smart_proxy_container_gateway/foreman_api.rb +13 -5
- data/lib/smart_proxy_container_gateway/sequel_migrations/003_authorization_reorg.rb +65 -0
- data/lib/smart_proxy_container_gateway/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8c0467076082cd7138c8415f2915d72c9b69fea3af5909d9c396ed4b247f5009
|
4
|
+
data.tar.gz: 66a1866562b0fb767f1a7c94f40ad3572d89a67966269db711dfd2ca967449e1
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d5fb41b0be01a09736defddc0ec23ccf2ef81199700194cf46fbbc4afaaa6b2ffeebb79afa6119822a50f225ae7675ce1ea19946c49ff96882912c924a752b02
|
7
|
+
data.tar.gz: 7098744ff44801ee8051e3bdcdb87d2d7379eada25517552683023ef0cdd5932a52bdffd4b874e22b465cc7309754cc09b87a7d2fd36365be020a006c5f732cc
|
@@ -2,7 +2,6 @@ require 'sinatra'
|
|
2
2
|
require 'smart_proxy_container_gateway/container_gateway'
|
3
3
|
require 'smart_proxy_container_gateway/container_gateway_main'
|
4
4
|
require 'smart_proxy_container_gateway/foreman_api'
|
5
|
-
require 'sequel'
|
6
5
|
require 'sqlite3'
|
7
6
|
|
8
7
|
module Proxy
|
@@ -10,7 +9,7 @@ module Proxy
|
|
10
9
|
class Api < ::Sinatra::Base
|
11
10
|
include ::Proxy::Log
|
12
11
|
helpers ::Proxy::Helpers
|
13
|
-
|
12
|
+
helpers ::Sinatra::Authorization::Helpers
|
14
13
|
|
15
14
|
get '/v1/_ping/?' do
|
16
15
|
Proxy::ContainerGateway.ping
|
@@ -18,6 +17,7 @@ module Proxy
|
|
18
17
|
|
19
18
|
get '/v2/?' do
|
20
19
|
if auth_header.present? && (auth_header.unauthorized_token? || auth_header.valid_user_token?)
|
20
|
+
response.headers['Docker-Distribution-API-Version'] = 'registry/2.0'
|
21
21
|
Proxy::ContainerGateway.ping
|
22
22
|
else
|
23
23
|
redirect_authorization_headers
|
@@ -26,31 +26,32 @@ module Proxy
|
|
26
26
|
end
|
27
27
|
|
28
28
|
get '/v2/:repository/manifests/:tag/?' do
|
29
|
-
|
30
|
-
redirect_authorization_headers
|
31
|
-
halt 401, "unauthorized"
|
32
|
-
end
|
29
|
+
handle_repo_auth(params, auth_header, request)
|
33
30
|
redirection_location = Proxy::ContainerGateway.manifests(params[:repository], params[:tag])
|
34
31
|
redirect to(redirection_location)
|
35
32
|
end
|
36
33
|
|
37
34
|
get '/v2/:repository/blobs/:digest/?' do
|
38
|
-
|
39
|
-
redirect_authorization_headers
|
40
|
-
halt 401, "unauthorized"
|
41
|
-
end
|
35
|
+
handle_repo_auth(params, auth_header, request)
|
42
36
|
redirection_location = Proxy::ContainerGateway.blobs(params[:repository], params[:digest])
|
43
37
|
redirect to(redirection_location)
|
44
38
|
end
|
45
39
|
|
46
40
|
get '/v1/search/?' do
|
47
41
|
# Checks for podman client and issues a 404 in that case. Podman
|
48
|
-
# examines the response from a /
|
42
|
+
# examines the response from a /v1/search request. If the result
|
49
43
|
# is a 4XX, it will then proceed with a request to /_catalog
|
50
44
|
if !request.env['HTTP_USER_AGENT'].nil? && request.env['HTTP_USER_AGENT'].downcase.include?('libpod')
|
51
45
|
halt 404, "not found"
|
52
46
|
end
|
53
47
|
|
48
|
+
if auth_header.present? && !auth_header.blank?
|
49
|
+
username = auth_header.v1_foreman_authorized_username
|
50
|
+
if username.nil?
|
51
|
+
halt 401, "unauthorized"
|
52
|
+
end
|
53
|
+
params[:user] = username
|
54
|
+
end
|
54
55
|
repositories = Proxy::ContainerGateway.v1_search(params)
|
55
56
|
|
56
57
|
content_type :json
|
@@ -58,13 +59,23 @@ module Proxy
|
|
58
59
|
end
|
59
60
|
|
60
61
|
get '/v2/_catalog/?' do
|
61
|
-
|
62
|
-
|
63
|
-
|
62
|
+
catalog = []
|
63
|
+
if auth_header.present?
|
64
|
+
if auth_header.unauthorized_token?
|
65
|
+
catalog = Proxy::ContainerGateway.catalog
|
66
|
+
elsif auth_header.valid_user_token?
|
67
|
+
catalog = Proxy::ContainerGateway.catalog(auth_header.user)
|
68
|
+
else
|
69
|
+
redirect_authorization_headers
|
70
|
+
halt 401, "unauthorized"
|
71
|
+
end
|
72
|
+
else
|
73
|
+
redirect_authorization_headers
|
74
|
+
halt 401, "unauthorized"
|
75
|
+
end
|
64
76
|
|
65
|
-
get '/v2/unauthenticated_repository_list/?' do
|
66
77
|
content_type :json
|
67
|
-
{ repositories:
|
78
|
+
{ repositories: catalog }.to_json
|
68
79
|
end
|
69
80
|
|
70
81
|
get '/v2/token' do
|
@@ -83,28 +94,56 @@ module Proxy
|
|
83
94
|
token_response_body = JSON.parse(token_response.body)
|
84
95
|
ContainerGateway.insert_token(request.params['account'], token_response_body['token'],
|
85
96
|
token_response_body['expires_at'])
|
97
|
+
|
98
|
+
repo_response = ForemanApi.new.fetch_user_repositories(auth_header.raw_header, request.params)
|
99
|
+
if repo_response.code.to_i != 200
|
100
|
+
halt repo_response.code.to_i, repo_response.body
|
101
|
+
else
|
102
|
+
ContainerGateway.update_user_repositories(request.params['account'],
|
103
|
+
JSON.parse(repo_response.body)['repositories'])
|
104
|
+
end
|
86
105
|
return token_response_body.to_json
|
87
106
|
end
|
88
107
|
end
|
89
108
|
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
109
|
+
get '/users/?' do
|
110
|
+
do_authorize_any
|
111
|
+
|
112
|
+
content_type :json
|
113
|
+
{ users: User.map(:name) }.to_json
|
114
|
+
end
|
115
|
+
|
116
|
+
put '/user_repository_mapping/?' do
|
117
|
+
do_authorize_any
|
118
|
+
|
119
|
+
ContainerGateway.update_user_repo_mapping(params)
|
120
|
+
{}
|
121
|
+
end
|
122
|
+
|
123
|
+
put '/repository_list/?' do
|
124
|
+
do_authorize_any
|
125
|
+
|
126
|
+
ContainerGateway.update_repository_list(params['repositories'])
|
127
|
+
{}
|
104
128
|
end
|
105
129
|
|
106
130
|
private
|
107
131
|
|
132
|
+
def handle_repo_auth(params, auth_header, request)
|
133
|
+
user_token_is_valid = false
|
134
|
+
# FIXME: Getting unauthenticated token here...
|
135
|
+
if auth_header.present? && auth_header.valid_user_token?
|
136
|
+
user_token_is_valid = true
|
137
|
+
username = auth_header.user.name
|
138
|
+
end
|
139
|
+
username = request.params['account'] if username.nil?
|
140
|
+
|
141
|
+
return if Proxy::ContainerGateway.authorized_for_repo?(params[:repository], user_token_is_valid, username)
|
142
|
+
|
143
|
+
redirect_authorization_headers
|
144
|
+
halt 401, "unauthorized"
|
145
|
+
end
|
146
|
+
|
108
147
|
def redirect_authorization_headers
|
109
148
|
response.headers['Docker-Distribution-API-Version'] = 'registry/2.0'
|
110
149
|
response.headers['Www-Authenticate'] = "Bearer realm=\"https://#{request.host}/v2/token\"," \
|
@@ -123,6 +162,10 @@ module Proxy
|
|
123
162
|
@value = value || ''
|
124
163
|
end
|
125
164
|
|
165
|
+
def user
|
166
|
+
ContainerGateway.token_user(@value.split(' ')[1])
|
167
|
+
end
|
168
|
+
|
126
169
|
def valid_user_token?
|
127
170
|
token_auth? && ContainerGateway.valid_token?(@value.split(' ')[1])
|
128
171
|
end
|
@@ -146,6 +189,19 @@ module Proxy
|
|
146
189
|
def basic_auth?
|
147
190
|
@value.split(' ')[0] == 'Basic'
|
148
191
|
end
|
192
|
+
|
193
|
+
def blank?
|
194
|
+
Base64.decode64(@value.split(' ')[1]) == ':'
|
195
|
+
end
|
196
|
+
|
197
|
+
# A special case for the V1 API. Defer authentication to Foreman and return the username. `nil` if not authorized.
|
198
|
+
def v1_foreman_authorized_username
|
199
|
+
username = Base64.decode64(@value.split(' ')[1]).split(':')[0]
|
200
|
+
auth_response = ForemanApi.new.fetch_token(raw_header, { 'account' => username })
|
201
|
+
return username if auth_response.code.to_i == 200 && (JSON.parse(auth_response.body)['token'] != 'unauthenticated')
|
202
|
+
|
203
|
+
nil
|
204
|
+
end
|
149
205
|
end
|
150
206
|
end
|
151
207
|
end
|
@@ -1,13 +1,14 @@
|
|
1
1
|
require 'net/http'
|
2
2
|
require 'uri'
|
3
3
|
require 'digest'
|
4
|
-
|
4
|
+
require 'sequel'
|
5
5
|
module Proxy
|
6
6
|
module ContainerGateway
|
7
7
|
extend ::Proxy::Util
|
8
8
|
extend ::Proxy::Log
|
9
9
|
|
10
10
|
class << self
|
11
|
+
Sequel.extension :migration, :core_extensions
|
11
12
|
def pulp_registry_request(uri)
|
12
13
|
http_client = Net::HTTP.new(uri.host, uri.port)
|
13
14
|
http_client.ca_file = pulp_ca
|
@@ -49,7 +50,8 @@ module Proxy
|
|
49
50
|
|
50
51
|
repo_count = 0
|
51
52
|
repositories = []
|
52
|
-
|
53
|
+
user = params[:user].nil? ? nil : User.find(name: params[:user])
|
54
|
+
Proxy::ContainerGateway.catalog(user).each do |repo_name|
|
53
55
|
break if repo_count >= params[:n]
|
54
56
|
|
55
57
|
if params[:q].nil? || params[:q] == "" || repo_name.include?(params[:q])
|
@@ -60,48 +62,103 @@ module Proxy
|
|
60
62
|
repositories
|
61
63
|
end
|
62
64
|
|
63
|
-
def catalog
|
64
|
-
|
65
|
+
def catalog(user = nil)
|
66
|
+
if user.nil?
|
67
|
+
unauthenticated_repos
|
68
|
+
else
|
69
|
+
(unauthenticated_repos + user.repositories_dataset.map(:name)).sort
|
70
|
+
end
|
65
71
|
end
|
66
72
|
|
67
73
|
def unauthenticated_repos
|
68
|
-
|
69
|
-
conn[:unauthenticated_repositories].order(:name).map(:name)
|
74
|
+
Repository.where(auth_required: false).order(:name).map(:name)
|
70
75
|
end
|
71
76
|
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
77
|
+
# Replaces the entire list of repositories
|
78
|
+
def update_repository_list(repo_list)
|
79
|
+
RepositoryUser.dataset.delete
|
80
|
+
Repository.dataset.delete
|
81
|
+
repo_list.each do |repo|
|
82
|
+
Repository.find_or_create(name: repo['repository'],
|
83
|
+
auth_required: repo['auth_required'].to_s.downcase == "true")
|
78
84
|
end
|
79
85
|
end
|
80
86
|
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
87
|
+
# Replaces the entire user-repo mapping for all logged-in users
|
88
|
+
def update_user_repo_mapping(user_repo_maps)
|
89
|
+
# Get hash map of all users and their repositories
|
90
|
+
# Ex: {"users"=> [{"admin"=>[{"repository"=>"repo", "auth_required"=>"true"}]}]}
|
91
|
+
# Go through list of repositories and add them to the DB
|
92
|
+
RepositoryUser.dataset.delete
|
93
|
+
user_repo_maps['users'].each do |user_repo_map|
|
94
|
+
user_repo_map.each do |user, repos|
|
95
|
+
repos.each do |repo|
|
96
|
+
found_repo = Repository.find(name: repo['repository'],
|
97
|
+
auth_required: repo['auth_required'].to_s.downcase == "true")
|
98
|
+
if found_repo.nil?
|
99
|
+
logger.warn("#{repo['repository']} does not exist in this smart proxy's environments")
|
100
|
+
elsif found_repo.auth_required
|
101
|
+
found_repo.add_user(User.find(name: user))
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# Replaces the user-repo mapping for a single user
|
109
|
+
def update_user_repositories(username, repositories)
|
110
|
+
user = User.where(name: username).first
|
111
|
+
user.remove_all_repositories
|
112
|
+
repositories.each do |repo_name|
|
113
|
+
found_repo = Repository.find(name: repo_name)
|
114
|
+
if found_repo.nil?
|
115
|
+
logger.warn("#{repo_name} does not exist in this smart proxy's environments")
|
116
|
+
elsif user.repositories_dataset.where(name: repo_name).first.nil? && found_repo.auth_required
|
117
|
+
user.add_repository(found_repo)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def authorized_for_repo?(repo_name, user_token_is_valid, username = nil)
|
123
|
+
repository = Repository.where(name: repo_name).first
|
124
|
+
|
125
|
+
# Repository doesn't exist
|
126
|
+
return false if repository.nil?
|
127
|
+
|
128
|
+
# Repository doesn't require auth
|
129
|
+
return true unless repository.auth_required
|
130
|
+
|
131
|
+
if username && user_token_is_valid && repository.auth_required
|
132
|
+
# User is logged in and has access to the repository
|
133
|
+
user = User.find(name: username)
|
134
|
+
return !user.repositories_dataset.where(name: repo_name).first.nil?
|
135
|
+
end
|
136
|
+
|
137
|
+
false
|
138
|
+
end
|
139
|
+
|
140
|
+
def token_user(token)
|
141
|
+
User[AuthenticationToken.find(token_checksum: Digest::SHA256.hexdigest(token)).user_id]
|
85
142
|
end
|
86
143
|
|
87
144
|
def valid_token?(token)
|
88
|
-
|
89
|
-
tokens.where(token_checksum: Digest::SHA256.hexdigest(token)).where do
|
145
|
+
AuthenticationToken.where(token_checksum: Digest::SHA256.hexdigest(token)).where do
|
90
146
|
expire_at > Sequel::CURRENT_TIMESTAMP
|
91
147
|
end.count.positive?
|
92
148
|
end
|
93
149
|
|
94
150
|
def insert_token(username, token, expire_at_string, clear_expired_tokens: true)
|
95
|
-
tokens = initialize_db[:authentication_tokens]
|
96
151
|
checksum = Digest::SHA256.hexdigest(token)
|
152
|
+
user = User.find_or_create(name: username)
|
97
153
|
|
98
|
-
|
99
|
-
|
100
|
-
|
154
|
+
AuthenticationToken.where(:token_checksum => checksum).delete
|
155
|
+
AuthenticationToken.create(token_checksum: checksum, expire_at: expire_at_string.to_s, user_id: user.id)
|
156
|
+
AuthenticationToken.where { expire_at < Sequel::CURRENT_TIMESTAMP }.delete if clear_expired_tokens
|
101
157
|
end
|
102
158
|
|
103
159
|
def initialize_db
|
104
|
-
|
160
|
+
file_path = Proxy::ContainerGateway::Plugin.settings.sqlite_db_path
|
161
|
+
conn = Sequel.connect("sqlite://#{file_path}")
|
105
162
|
container_gateway_path = $LOAD_PATH.detect { |path| path.include? 'smart_proxy_container_gateway' }
|
106
163
|
begin
|
107
164
|
Sequel::Migrator.check_current(conn, "#{container_gateway_path}/smart_proxy_container_gateway/sequel_migrations")
|
@@ -131,5 +188,20 @@ module Proxy
|
|
131
188
|
)
|
132
189
|
end
|
133
190
|
end
|
191
|
+
|
192
|
+
class Repository < ::Sequel::Model(Proxy::ContainerGateway.initialize_db[:repositories])
|
193
|
+
many_to_many :users
|
194
|
+
end
|
195
|
+
|
196
|
+
class User < ::Sequel::Model(Proxy::ContainerGateway.initialize_db[:users])
|
197
|
+
many_to_many :repositories
|
198
|
+
one_to_many :authentication_tokens
|
199
|
+
end
|
200
|
+
|
201
|
+
class RepositoryUser < ::Sequel::Model(Proxy::ContainerGateway.initialize_db[:repositories_users]); end
|
202
|
+
|
203
|
+
class AuthenticationToken < ::Sequel::Model(Proxy::ContainerGateway.initialize_db[:authentication_tokens])
|
204
|
+
many_to_one :users
|
205
|
+
end
|
134
206
|
end
|
135
207
|
end
|
@@ -3,15 +3,15 @@ require 'uri'
|
|
3
3
|
module Proxy
|
4
4
|
module ContainerGateway
|
5
5
|
class ForemanApi
|
6
|
-
def
|
7
|
-
|
8
|
-
|
6
|
+
def registry_request(auth_header, params, suffix)
|
7
|
+
uri = URI.join(Proxy::SETTINGS.foreman_url, Proxy::ContainerGateway::Plugin.settings.katello_registry_path, suffix)
|
8
|
+
uri.query = process_params(params)
|
9
9
|
|
10
|
-
req = Net::HTTP::Get.new(
|
10
|
+
req = Net::HTTP::Get.new(uri)
|
11
11
|
req.add_field('Authorization', auth_header)
|
12
12
|
req.add_field('Accept', 'application/json')
|
13
13
|
req.content_type = 'application/json'
|
14
|
-
http = Net::HTTP.new(
|
14
|
+
http = Net::HTTP.new(uri.hostname, uri.port)
|
15
15
|
http.use_ssl = true
|
16
16
|
|
17
17
|
http.request(req)
|
@@ -21,6 +21,14 @@ module Proxy
|
|
21
21
|
params = params_in.slice('scope', 'account').compact
|
22
22
|
URI.encode_www_form(params)
|
23
23
|
end
|
24
|
+
|
25
|
+
def fetch_token(auth_header, params)
|
26
|
+
registry_request(auth_header, params, 'token')
|
27
|
+
end
|
28
|
+
|
29
|
+
def fetch_user_repositories(auth_header, params)
|
30
|
+
registry_request(auth_header, params, '_catalog')
|
31
|
+
end
|
24
32
|
end
|
25
33
|
end
|
26
34
|
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
Sequel.migration do
|
2
|
+
up do
|
3
|
+
# TODO: Should I be migrating the existing data?
|
4
|
+
|
5
|
+
create_table(:repositories) do
|
6
|
+
primary_key :id
|
7
|
+
String :name, null: false
|
8
|
+
Boolean :auth_required, null: false
|
9
|
+
end
|
10
|
+
|
11
|
+
create_table(:users) do
|
12
|
+
primary_key :id
|
13
|
+
String :name, null: false
|
14
|
+
end
|
15
|
+
|
16
|
+
# Migrate unauthenticated_repositories to the new repositories table (TODO: can I select `false` like that?)
|
17
|
+
from(:repositories).insert(%i[name auth_required],
|
18
|
+
from(:unauthenticated_repositories).select(:name, false))
|
19
|
+
|
20
|
+
# Migrate names from authentication_tokens to the new users table
|
21
|
+
from(:users).insert([:name], from(:authentication_tokens).select(:username))
|
22
|
+
|
23
|
+
alter_table(:authentication_tokens) do
|
24
|
+
add_foreign_key :user_id, :users
|
25
|
+
end
|
26
|
+
|
27
|
+
# Populate the new user_id foreign key for all authentication_tokens
|
28
|
+
from(:authentication_tokens).insert([:user_id],
|
29
|
+
from(:users).select(:id).where(name: self[:authentication_tokens][:username]))
|
30
|
+
|
31
|
+
alter_table(:authentication_tokens) do
|
32
|
+
drop_column :username
|
33
|
+
end
|
34
|
+
|
35
|
+
create_join_table(repository_id: :repositories, user_id: :users)
|
36
|
+
drop_table :unauthenticated_repositories
|
37
|
+
end
|
38
|
+
|
39
|
+
down do
|
40
|
+
alter_table(:authentication_tokens) do
|
41
|
+
add_column :username, String
|
42
|
+
end
|
43
|
+
|
44
|
+
# Repopulate the name column with usernames
|
45
|
+
from(:authentication_tokens).update(username:
|
46
|
+
from(:users).select(:name).where(id: self[:authentication_tokens][:user_id]))
|
47
|
+
|
48
|
+
alter_table(:authentication_tokens) do
|
49
|
+
drop_foreign_key :user_id
|
50
|
+
end
|
51
|
+
|
52
|
+
create_table(:unauthenticated_repositories) do
|
53
|
+
primary_key :id
|
54
|
+
String :name, null: false
|
55
|
+
end
|
56
|
+
|
57
|
+
# Repopulate the unauthenticated_repositories table
|
58
|
+
from(:unauthenticated_repositories).insert([:username],
|
59
|
+
from(:repositories).select(:name).where(auth_required: true))
|
60
|
+
|
61
|
+
drop_table :users
|
62
|
+
drop_table :repositories
|
63
|
+
drop_join_table(repository_id: :repositories, user_id: :users)
|
64
|
+
end
|
65
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: smart_proxy_container_gateway
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.0.
|
4
|
+
version: 1.0.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ian Ballou
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2021-04-
|
11
|
+
date: 2021-04-26 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: sequel
|
@@ -57,6 +57,7 @@ files:
|
|
57
57
|
- lib/smart_proxy_container_gateway/foreman_api.rb
|
58
58
|
- lib/smart_proxy_container_gateway/sequel_migrations/001_initial.rb
|
59
59
|
- lib/smart_proxy_container_gateway/sequel_migrations/002_auth_tokens.rb
|
60
|
+
- lib/smart_proxy_container_gateway/sequel_migrations/003_authorization_reorg.rb
|
60
61
|
- lib/smart_proxy_container_gateway/version.rb
|
61
62
|
- settings.d/container_gateway.yml.example
|
62
63
|
homepage: http://github.com/ianballou/smart_proxy_container_gateway
|