workhorse.rb 7.93 KB
Newer Older
1
2
require 'base64'
require 'json'
3
require 'securerandom'
4
require 'uri'
5
6
7

module Gitlab
  class Workhorse
Douwe Maan's avatar
Douwe Maan committed
8
9
10
11
    SEND_DATA_HEADER = 'Gitlab-Workhorse-Send-Data'.freeze
    VERSION_FILE = 'GITLAB_WORKHORSE_VERSION'.freeze
    INTERNAL_API_CONTENT_TYPE = 'application/vnd.gitlab-workhorse+json'.freeze
    INTERNAL_API_REQUEST_HEADER = 'Gitlab-Workhorse-Api-Request'.freeze
12
    NOTIFICATION_CHANNEL = 'workhorse:notifications'.freeze
Kim "BKC" Carlbäcker's avatar
Kim "BKC" Carlbäcker committed
13
    ALLOWED_GIT_HTTP_ACTIONS = %w[git_receive_pack git_upload_pack info_refs].freeze
14
15
16
17

    # Supposedly the effective key size for HMAC-SHA256 is 256 bits, i.e. 32
    # bytes https://tools.ietf.org/html/rfc4868#section-2.6
    SECRET_LENGTH = 32
18

Jacob Vosmaer's avatar
Fix API    
Jacob Vosmaer committed
19
    class << self
20
      def git_http_ok(repository, is_wiki, user, action, show_all_refs: false)
Kim "BKC" Carlbäcker's avatar
Kim "BKC" Carlbäcker committed
21
        raise "Unsupported action: #{action}" unless ALLOWED_GIT_HTTP_ACTIONS.include?(action.to_s)
22

23
        project = repository.project
24
25

        {
26
          GL_ID: Gitlab::GlId.gl_id(user),
27
          GL_REPOSITORY: Gitlab::GlRepository.gl_repository(project, is_wiki),
28
          GL_USERNAME: user&.username,
29
30
31
32
33
34
35
          ShowAllRefs: show_all_refs,
          Repository: repository.gitaly_repository.to_h,
          RepoPath: 'ignored but not allowed to be empty in gitlab-workhorse',
          GitalyServer: {
            address: Gitlab::GitalyClient.address(project.repository_storage),
            token: Gitlab::GitalyClient.token(project.repository_storage)
          }
36
        }
37
38
      end

39
      def send_git_blob(repository, blob)
40
        params = if Gitlab::GitalyClient.feature_enabled?(:workhorse_raw_show, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT)
41
42
43
44
45
46
47
48
49
50
51
52
53
54
                   {
                     'GitalyServer' => gitaly_server_hash(repository),
                     'GetBlobRequest' => {
                       repository: repository.gitaly_repository.to_h,
                       oid: blob.id,
                       limit: -1
                     }
                   }
                 else
                   {
                     'RepoPath' => repository.path_to_repo,
                     'BlobId' => blob.id
                   }
                 end
55
56

        [
57
          SEND_DATA_HEADER,
Douwe Maan's avatar
Douwe Maan committed
58
          "git-blob:#{encode(params)}"
59
60
        ]
      end
61

62
      def send_git_archive(repository, ref:, format:, append_sha:)
63
64
        format ||= 'tar.gz'
        format.downcase!
65
        params = repository.archive_metadata(ref, Gitlab.config.gitlab.repository_downloads_path, format, append_sha: append_sha)
66
67
        raise "Repository or ref not found" if params.empty?

68
        params['GitalyServer'] = gitaly_server_hash(repository)
69

70
71
72
        # If present DisableCache must be a Boolean. Otherwise workhorse ignores it.
        params['DisableCache'] = true if git_archive_cache_disabled?

73
74
        [
          SEND_DATA_HEADER,
Douwe Maan's avatar
Douwe Maan committed
75
          "git-archive:#{encode(params)}"
76
77
        ]
      end
78

79
80
81
82
83
84
85
86
87
88
89
90
91
92
      def send_git_snapshot(repository)
        params = {
          'GitalyServer' => gitaly_server_hash(repository),
          'GetSnapshotRequest' => Gitaly::GetSnapshotRequest.new(
            repository: repository.gitaly_repository
          ).to_json
        }

        [
          SEND_DATA_HEADER,
          "git-snapshot:#{encode(params)}"
        ]
      end

Douwe Maan's avatar
Douwe Maan committed
93
      def send_git_diff(repository, diff_refs)
94
        params = if Gitlab::GitalyClient.feature_enabled?(:workhorse_send_git_diff, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT)
95
96
97
98
99
100
101
102
103
                   {
                     'GitalyServer' => gitaly_server_hash(repository),
                     'RawDiffRequest' => Gitaly::RawDiffRequest.new(
                       gitaly_diff_or_patch_hash(repository, diff_refs)
                     ).to_json
                   }
                 else
                   workhorse_diff_or_patch_hash(repository, diff_refs)
                 end
104
105
106
107

        [
          SEND_DATA_HEADER,
          "git-diff:#{encode(params)}"
108
109
        ]
      end
110

Douwe Maan's avatar
Douwe Maan committed
111
      def send_git_patch(repository, diff_refs)
112
        params = if Gitlab::GitalyClient.feature_enabled?(:workhorse_send_git_patch, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT)
113
114
115
116
117
118
119
120
121
                   {
                     'GitalyServer' => gitaly_server_hash(repository),
                     'RawPatchRequest' => Gitaly::RawPatchRequest.new(
                       gitaly_diff_or_patch_hash(repository, diff_refs)
                     ).to_json
                   }
                 else
                   workhorse_diff_or_patch_hash(repository, diff_refs)
                 end
122
123

        [
Z.J. van de Weg's avatar
Z.J. van de Weg committed
124
          SEND_DATA_HEADER,
125
126
127
128
          "git-format-patch:#{encode(params)}"
        ]
      end

129
      def send_artifacts_entry(build, entry)
130
131
132
        file = build.artifacts_file
        archive = file.file_storage? ? file.path : file.url

133
        params = {
134
          'Archive' => archive,
135
          'Entry' => Base64.encode64(entry.to_s)
136
137
138
139
140
141
142
143
        }

        [
          SEND_DATA_HEADER,
          "artifacts-entry:#{encode(params)}"
        ]
      end

144
145
146
147
148
149
150
151
152
153
154
155
      def send_url(url, allow_redirects: false)
        params = {
          'URL' => url,
          'AllowRedirects' => allow_redirects
        }

        [
          SEND_DATA_HEADER,
          "send-url:#{encode(params)}"
        ]
      end

156
157
158
159
160
      def terminal_websocket(terminal)
        details = {
          'Terminal' => {
            'Subprotocols' => terminal[:subprotocols],
            'Url' => terminal[:url],
161
            'Header' => terminal[:headers],
162
            'MaxSessionTime' => terminal[:max_session_time]
163
164
          }
        }
165
        details['Terminal']['CAPem'] = terminal[:ca_pem] if terminal.key?(:ca_pem)
166
167
168
169

        details
      end

170
      def version
171
172
        path = Rails.root.join(VERSION_FILE)
        path.readable? ? path.read.chomp : 'unknown'
173
174
      end

175
176
      def secret
        @secret ||= begin
177
          bytes = Base64.strict_decode64(File.read(secret_path).chomp)
178
          raise "#{secret_path} does not contain #{SECRET_LENGTH} bytes" if bytes.length != SECRET_LENGTH
179

180
181
182
          bytes
        end
      end
183

184
185
      def write_secret
        bytes = SecureRandom.random_bytes(SECRET_LENGTH)
186
        File.open(secret_path, 'w:BINARY', 0600) do |f|
Jacob Vosmaer's avatar
Jacob Vosmaer committed
187
          f.chmod(0600) # If the file already existed, the '0600' passed to 'open' above was a no-op.
188
189
190
          f.write(Base64.strict_encode64(bytes))
        end
      end
191

192
      def verify_api_request!(request_headers)
193
194
195
196
        decode_jwt(request_headers[INTERNAL_API_REQUEST_HEADER])
      end

      def decode_jwt(encoded_message)
197
        JWT.decode(
198
          encoded_message,
199
200
          secret,
          true,
201
          { iss: 'gitlab-workhorse', verify_iss: true, algorithm: 'HS256' }
202
203
204
205
        )
      end

      def secret_path
206
        Gitlab.config.workhorse.secret_file
207
      end
208

Kamil Trzcinski's avatar
Kamil Trzcinski committed
209
      def set_key_and_notify(key, value, expire: nil, overwrite: true)
210
        Gitlab::Redis::Queues.with do |redis|
211
212
          result = redis.set(key, value, ex: expire, nx: !overwrite)
          if result
Kamil Trzcinski's avatar
Kamil Trzcinski committed
213
            redis.publish(NOTIFICATION_CHANNEL, "#{key}=#{value}")
214
215
216
217
218
219
220
            value
          else
            redis.get(key)
          end
        end
      end

221
      protected
222

223
224
225
      def encode(hash)
        Base64.urlsafe_encode64(JSON.dump(hash))
      end
226
227
228
229
230
231
232

      def gitaly_server_hash(repository)
        {
          address: Gitlab::GitalyClient.address(repository.project.repository_storage),
          token: Gitlab::GitalyClient.token(repository.project.repository_storage)
        }
      end
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248

      def workhorse_diff_or_patch_hash(repository, diff_refs)
        {
          'RepoPath'  => repository.path_to_repo,
          'ShaFrom'   => diff_refs.base_sha,
          'ShaTo'     => diff_refs.head_sha
        }
      end

      def gitaly_diff_or_patch_hash(repository, diff_refs)
        {
          repository: repository.gitaly_repository,
          left_commit_id: diff_refs.base_sha,
          right_commit_id: diff_refs.head_sha
        }
      end
249
250
251
252

      def git_archive_cache_disabled?
        ENV['WORKHORSE_ARCHIVE_CACHE_DISABLED'].present? || Feature.enabled?(:workhorse_archive_cache_disabled)
      end
253
254
    end
  end
Jacob Vosmaer's avatar
Jacob Vosmaer committed
255
end