diff --git a/Gemfile b/Gemfile index e795da787ad850fc2654f96357542ba4fc3d3eb2..9575e6d0cc2dc85d246c62cb6e848594fd82999b 100644 --- a/Gemfile +++ b/Gemfile @@ -46,6 +46,9 @@ gem 'devise-two-factor', '~> 2.0.0' gem 'rqrcode-rails3', '~> 0.1.7' gem 'attr_encrypted', '~> 1.3.4' +# GitLab Pages +gem 'validates_hostname', '~> 1.0.0' + # Browser detection gem "browser", '~> 1.0.0' diff --git a/Gemfile.lock b/Gemfile.lock index d35964085a8123128ac14d07c76a2159e1b601dd..4c940c9367d76e4fbf54b834664d77d46bfa9ae5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -867,6 +867,9 @@ GEM uniform_notifier (1.9.0) uuid (2.3.8) macaddr (~> 1.0) + validates_hostname (1.0.5) + activerecord (>= 3.0) + activesupport (>= 3.0) version_sorter (2.0.0) virtus (1.0.5) axiom-types (~> 0.1) @@ -1066,6 +1069,7 @@ DEPENDENCIES unf (~> 0.1.4) unicorn (~> 4.8.2) unicorn-worker-killer (~> 0.4.2) + validates_hostname (~> 1.0.0) version_sorter (~> 2.0.0) virtus (~> 1.0.1) web-console (~> 2.0) diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..ef0ed50514236a2ad3862ac92bdb4b8b03805bc9 --- /dev/null +++ b/app/controllers/projects/pages_controller.rb @@ -0,0 +1,94 @@ +class Projects::PagesController < Projects::ApplicationController + layout 'project_settings' + + before_action :authorize_update_pages!, except: [:show] + before_action :authorize_remove_pages!, only: :destroy + + helper_method :valid_certificate?, :valid_certificate_key? + helper_method :valid_key_for_certificiate?, :valid_certificate_intermediates? + helper_method :certificate, :certificate_key + + def show + end + + def update + if @project.update_attributes(pages_params) + redirect_to namespace_project_pages_path(@project.namespace, @project) + else + render 'show' + end + end + + def certificate + @project.remove_pages_certificate + end + + def destroy + @project.remove_pages + + respond_to do |format| + format.html { redirect_to project_path(@project) } + end + end + + private + + def pages_params + params.require(:project).permit( + :pages_custom_certificate, + :pages_custom_certificate_key, + :pages_custom_domain, + :pages_redirect_http, + ) + end + + def valid_certificate? + certificate.present? + end + + def valid_certificate_key? + certificate_key.present? + end + + def valid_key_for_certificiate? + return false unless certificate + return false unless certificate_key + + certificate.verify(certificate_key) + rescue OpenSSL::X509::CertificateError + false + end + + def valid_certificate_intermediates? + return false unless certificate + + store = OpenSSL::X509::Store.new + store.set_default_paths + + # This forces to load all intermediate certificates stored in `pages_custom_certificate` + Tempfile.open('project_certificate') do |f| + f.write(@project.pages_custom_certificate) + f.flush + store.add_file(f.path) + end + + store.verify(certificate) + rescue OpenSSL::X509::StoreError + false + end + + def certificate + return unless @project.pages_custom_certificate + + @certificate ||= OpenSSL::X509::Certificate.new(@project.pages_custom_certificate) + rescue OpenSSL::X509::CertificateError + nil + end + + def certificate_key + return unless @project.pages_custom_certificate_key + @certificate_key ||= OpenSSL::PKey::RSA.new(@project.pages_custom_certificate_key) + rescue OpenSSL::PKey::PKeyError + nil + end +end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 6393397000a01c6f9b3410c702fe5020c363feb5..95d851a69372aadb15e62c4cfc2c7843567016d6 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -171,16 +171,6 @@ def unarchive end end - def remove_pages - return access_denied! unless can?(current_user, :remove_pages, @project) - - @project.remove_pages - - respond_to do |format| - format.html { redirect_to project_path(@project) } - end - end - def housekeeping ::Projects::HousekeepingService.new(@project).execute diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 93d530ef76942fa9706701f9992531609f2a5e1e..8dd31a895ec49a9e11306b53974cfad96fe36c9e 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -90,6 +90,14 @@ def remove_fork_project_message(project) "You are going to remove the fork relationship to source project #{@project.forked_from_project.name_with_namespace}. Are you ABSOLUTELY sure?" end + def remove_pages_message(project) + "You are going to remove the pages for #{project.name_with_namespace}.\n Are you ABSOLUTELY sure?" + end + + def remove_pages_certificate_message(project) + "You are going to remove a certificates for #{project.name_with_namespace}.\n Are you ABSOLUTELY sure?" + end + def project_nav_tabs @nav_tabs ||= get_project_nav_tabs(@project, current_user) end diff --git a/app/models/ability.rb b/app/models/ability.rb index 29fcaf583fc9fb12c53ba12e26081631941802cd..38482c1de2cde1d676d43c314f14cd5ffd0d5b43 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -245,6 +245,7 @@ def project_admin_rules :change_visibility_level, :rename_project, :remove_project, + :update_pages, :remove_pages, :archive_project, :remove_fork_project diff --git a/app/models/project.rb b/app/models/project.rb index da08add18e6ec23e68e0f5c37ca4b3f24114bbcd..08faaa58414ab4d64975dfa7bed724d1304c0633 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -95,6 +95,8 @@ def update_forks_visibility_level attr_accessor :new_default_branch attr_accessor :old_path_with_namespace + attr_encrypted :pages_custom_certificate_key, mode: :per_attribute_iv_and_salt, key: Gitlab::Application.secrets.db_key_base + # Relations belongs_to :creator, foreign_key: 'creator_id', class_name: 'User' belongs_to :group, -> { where(type: Group) }, foreign_key: 'namespace_id' @@ -209,6 +211,11 @@ def update_forks_visibility_level validates :avatar, file_size: { maximum: 200.kilobytes.to_i } validates :approvals_before_merge, numericality: true, allow_blank: true + validates :pages_custom_domain, hostname: true, allow_blank: true, allow_nil: true + validates_uniqueness_of :pages_custom_domain, allow_nil: true, allow_blank: true + validates :pages_custom_certificate, certificate: { intermediate: true } + validates :pages_custom_certificate_key, certificate_key: true + add_authentication_token_field :runners_token before_save :ensure_runners_token @@ -1046,16 +1053,27 @@ def runners_token end def pages_url - if Dir.exist?(public_pages_path) - host = "#{namespace.path}.#{Settings.pages.host}" - url = Gitlab.config.pages.url.sub(/^https?:\/\//) do |prefix| - "#{prefix}#{namespace.path}." - end + return unless Dir.exist?(public_pages_path) + + host = "#{namespace.path}.#{Settings.pages.host}" + url = Gitlab.config.pages.url.sub(/^https?:\/\//) do |prefix| + "#{prefix}#{namespace.path}." + end + + # If the project path is the same as host, leave the short version + return url if host == path + + "#{url}/#{path}" + end - # If the project path is the same as host, leave the short version - return url if host == path + def pages_custom_url + return unless pages_custom_domain + return unless Dir.exist?(public_pages_path) - "#{url}/#{path}" + if Gitlab.config.pages.https + return "https://#{pages_custom_domain}" + else + return "http://#{pages_custom_domain}" end end @@ -1067,6 +1085,15 @@ def public_pages_path File.join(pages_path, 'public') end + def remove_pages_certificate + update( + pages_custom_certificate: nil, + pages_custom_certificate_key: nil + ) + + UpdatePagesConfigurationService.new(self).execute + end + def remove_pages # 1. We rename pages to temporary directory # 2. We wait 5 minutes, due to NFS caching @@ -1076,6 +1103,14 @@ def remove_pages if Gitlab::PagesTransfer.new.rename_project(path, temp_path, namespace.path) PagesWorker.perform_in(5.minutes, :remove, namespace.path, temp_path) end + + update( + pages_custom_certificate: nil, + pages_custom_certificate_key: nil, + pages_custom_domain: nil + ) + + UpdatePagesConfigurationService.new(self).execute end def merge_method diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..be4c2fbef8c3577f4488c5b9ac0e059a2b7c5293 --- /dev/null +++ b/app/services/projects/update_pages_configuration_service.rb @@ -0,0 +1,53 @@ +module Projects + class UpdatePagesConfigurationService < BaseService + attr_reader :project + + def initialize(project) + @project = project + end + + def execute + update_file(pages_cname_file, project.pages_custom_domain) + update_file(pages_certificate_file, project.pages_custom_certificate) + update_file(pages_certificate_file_key, project.pages_custom_certificate_key) + reload_daemon + success + rescue => e + error(e.message) + end + + private + + def reload_daemon + # GitLab Pages daemon constantly watches for modification time of `pages.path` + # It reloads configuration when `pages.path` is modified + File.touch(Settings.pages.path) + end + + def pages_path + @pages_path ||= project.pages_path + end + + def pages_cname_file + File.join(pages_path, 'CNAME') + end + + def pages_certificate_file + File.join(pages_path, 'domain.crt') + end + + def pages_certificate_key_file + File.join(pages_path, 'domain.key') + end + + def update_file(file, data) + if data + File.open(file, 'w') do |file| + file.write(data) + end + else + File.rm_r(file) + end + end + end +end diff --git a/app/validators/certificate_key_validator.rb b/app/validators/certificate_key_validator.rb new file mode 100644 index 0000000000000000000000000000000000000000..3b5bd30db1ae414fd3787fd962317e8b9178e426 --- /dev/null +++ b/app/validators/certificate_key_validator.rb @@ -0,0 +1,24 @@ +# UrlValidator +# +# Custom validator for private keys. +# +# class Project < ActiveRecord::Base +# validates :certificate_key, certificate_key: true +# end +# +class CertificateKeyValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + unless valid_private_key_pem?(value) + record.errors.add(attribute, "must be a valid PEM private key") + end + end + + private + + def valid_private_key_pem?(value) + pkey = OpenSSL::PKey::RSA.new(value) + pkey.private? + rescue OpenSSL::PKey::PKeyError + false + end +end diff --git a/app/validators/certificate_validator.rb b/app/validators/certificate_validator.rb new file mode 100644 index 0000000000000000000000000000000000000000..2cba5a435b7d430e0995e57ecbb295545d991375 --- /dev/null +++ b/app/validators/certificate_validator.rb @@ -0,0 +1,30 @@ +# UrlValidator +# +# Custom validator for private keys. +# +# class Project < ActiveRecord::Base +# validates :certificate_key, certificate_key: true +# end +# +class CertificateValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + certificate = parse_certificate(value) + unless certificate + record.errors.add(attribute, "must be a valid PEM certificate") + end + + if options[:intermediates] + unless certificate + record.errors.add(attribute, "certificate verification failed: missing intermediate certificates") + end + end + end + + private + + def parse_certificate(value) + OpenSSL::X509::Certificate.new(value) + rescue OpenSSL::X509::CertificateError + nil + end +end diff --git a/app/views/layouts/nav/_project_settings.html.haml b/app/views/layouts/nav/_project_settings.html.haml index aea70b451e775d0bbe1d5b011f3cf2012ee9a8b8..80a621edfbfeed1fe66381e48afd605bb5b286b1 100644 --- a/app/views/layouts/nav/_project_settings.html.haml +++ b/app/views/layouts/nav/_project_settings.html.haml @@ -49,6 +49,11 @@ = icon('clone fw') %span Mirror Repository + = nav_link(controller: :pages) do + = link_to namespace_project_pages_path(@project.namespace, @project), title: 'Pages', data: {placement: 'right'} do + = icon('clone fw') + %span + Pages = nav_link(controller: :audit_events) do = link_to namespace_project_audit_events_path(@project.namespace, @project) do = icon('file-text-o fw') diff --git a/app/views/projects/pages/_access.html.haml b/app/views/projects/pages/_access.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..d64f99fd22b98e7b2b178006c3a61bee68e442ca --- /dev/null +++ b/app/views/projects/pages/_access.html.haml @@ -0,0 +1,34 @@ +- if @project.pages_url + .panel.panel-default + .panel-heading + Access pages + .panel-body + %p + %strong + Congratulations! Your pages are served at: + %p= link_to @project.pages_url, @project.pages_url + + - if Settings.pages.custom_domain && @project.pages_custom_url + %p= link_to @project.pages_custom_url, @project.pages_custom_url + + - if @project.pages_custom_certificate + - unless valid_certificate? + #error_explanation + .alert.alert-warning + Your certificate is invalid. + + - unless valid_certificate_key? + #error_explanation + .alert.alert-warning + Your private key is invalid. + + - unless valid_key_for_certificiate? + #error_explanation + .alert.alert-warning + Your private key can't be used with your certificate. + + - unless valid_certificate_intermediates? + #error_explanation + .alert.alert-warning + Your certificate doesn't have intermediates. + Your page may not work properly. diff --git a/app/views/projects/pages/_destroy.haml b/app/views/projects/pages/_destroy.haml new file mode 100644 index 0000000000000000000000000000000000000000..61b995a59345b32b1f4baac6ec98946029829a9d --- /dev/null +++ b/app/views/projects/pages/_destroy.haml @@ -0,0 +1,10 @@ +- if can?(current_user, :remove_pages, @project) && @project.pages_url + .panel.panel-default.panel.panel-danger + .panel-heading Remove pages + .errors-holder + .panel-body + = form_tag(namespace_project_pages_path(@project.namespace, @project), method: :delete, class: 'form-horizontal') do + %p + Removing the pages will prevent from exposing them to outside world. + .form-actions + = button_to 'Remove pages', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_pages_message(@project) } diff --git a/app/views/projects/pages/_disabled.html.haml b/app/views/projects/pages/_disabled.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..cf9ef5b4d6f63e50bc15c8d7bdac60805b36e077 --- /dev/null +++ b/app/views/projects/pages/_disabled.html.haml @@ -0,0 +1,4 @@ +.panel.panel-default + .nothing-here-block + GitLab Pages is disabled. + Ask your system's administrator to enable it. diff --git a/app/views/projects/pages/_form.html.haml b/app/views/projects/pages/_form.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..a7b03d552dbf9ae4a26f105f53001456d9f0ef0b --- /dev/null +++ b/app/views/projects/pages/_form.html.haml @@ -0,0 +1,35 @@ +- if can?(current_user, :update_pages, @project) + .panel.panel-default + .panel-heading + Settings + .panel-body + = form_for [@project], url: namespace_project_pages_path(@project.namespace, @project), html: { class: 'form-horizontal fieldset-form' } do |f| + - if @project.errors.any? + #error_explanation + .alert.alert-danger + - @project.errors.full_messages.each do |msg| + %p= msg + + .form-group + = f.label :pages_domain, class: 'control-label' do + Custom domain + .col-sm-10 + - if Settings.pages.custom_domain + = f.text_field :pages_custom_domain, required: false, autocomplete: 'off', class: 'form-control' + %span.help-inline Allows you to serve the pages under your domain + - else + .nothing-here-block + Support for custom domains and certificates is disabled. + Ask your system's administrator to enable it. + + - if Settings.pages.https + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :pages_redirect_http do + = f.check_box :pages_redirect_http + %span.descr Force HTTPS + .help-block Redirect the HTTP to HTTPS forcing to always use the secure connection + + .form-actions + = f.submit 'Save changes', class: "btn btn-save" diff --git a/app/views/projects/pages/_remove_certificate.html.haml b/app/views/projects/pages/_remove_certificate.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..e8c0d03adfa85baf02a3eea2568bf01e78f9c025 --- /dev/null +++ b/app/views/projects/pages/_remove_certificate.html.haml @@ -0,0 +1,16 @@ +- if can?(current_user, :update_pages, @project) && @project.pages_custom_certificate + .panel.panel-default.panel.panel-danger + .panel-heading + Remove certificate + .errors-holder + .panel-body + = form_tag(certificates_namespace_project_pages_path(@project.namespace, @project), method: :delete, class: 'form-horizontal') do + %p + Removing the certificate will stop serving the page under HTTPS. + - if certificate + %p + %pre + = certificate.to_text + + .form-actions + = button_to 'Remove certificate', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_pages_certificate_message(@project) } diff --git a/app/views/projects/pages/_upload_certificate.html.haml b/app/views/projects/pages/_upload_certificate.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..30873fcf39530a7e088b23c3d3ebbf05c405d53d --- /dev/null +++ b/app/views/projects/pages/_upload_certificate.html.haml @@ -0,0 +1,32 @@ +- if can?(current_user, :update_pages, @project) && Settings.pages.https && Settings.pages.custom_domain + .panel.panel-default + .panel-heading + Certificate + .panel-body + %p + Allows you to upload your certificate which will be used to serve pages under your domain. + %br + + = form_for [@project], url: namespace_project_pages_path(@project.namespace, @project), html: { class: 'form-horizontal fieldset-form' } do |f| + - if @project.errors.any? + #error_explanation + .alert.alert-danger + - @project.errors.full_messages.each do |msg| + %p= msg + + .form-group + = f.label :pages_custom_certificate, class: 'control-label' do + Certificate (PEM) + .col-sm-10 + = f.text_area :pages_custom_certificate, required: true, rows: 5, class: 'form-control', value: '' + %span.help-inline Upload a certificate for your domain with all intermediates + + .form-group + = f.label :pages_custom_certificate_key, class: 'control-label' do + Key (PEM) + .col-sm-10 + = f.text_area :pages_custom_certificate_key, required: true, rows: 5, class: 'form-control', value: '' + %span.help-inline Upload a certificate for your domain with all intermediates + + .form-actions + = f.submit 'Update certificate', class: "btn btn-save" diff --git a/app/views/projects/pages/_use.html.haml b/app/views/projects/pages/_use.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..5542bbe670baad3fa825bc6a19286e02c41e36c5 --- /dev/null +++ b/app/views/projects/pages/_use.html.haml @@ -0,0 +1,18 @@ +- unless @project.pages_url + .panel.panel-info + .panel-heading + Configure pages + .panel-body + %p + Learn how to upload your static site and have it served by + GitLab by following the #{link_to "documentation on GitLab Pages", "http://doc.gitlab.com/ee/pages/README.html", target: :blank}. + %p + In the example below we define a special job named + %code pages + which is using Jekyll to build a static site. The generated + HTML will be stored in the + %code public/ + directory which will then be archived and uploaded to GitLab. + The name of the directory should not be different than + %code public/ + in order for the pages to work. diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..5f689800da84e53ca830101c2047e2e20b75b8ba --- /dev/null +++ b/app/views/projects/pages/show.html.haml @@ -0,0 +1,18 @@ +- page_title "Pages" +%h3.page_title Pages +%p.light + With GitLab Pages you can host for free your static websites on GitLab. + Combined with the power of GitLab CI and the help of GitLab Runner + you can deploy static pages for your individual projects, your user or your group. +%hr + +- if Settings.pages.enabled + = render 'access' + = render 'use' + - if @project.pages_url + = render 'form' + = render 'upload_certificate' + = render 'remove_certificate' + = render 'destroy' +- else + = render 'disabled' diff --git a/app/workers/pages_worker.rb b/app/workers/pages_worker.rb index 8c99e8dbe763c31f951e757d7db2b78e67bb40c1..4eeb9666bb0d443001d772b3ac0ed16f3a184385 100644 --- a/app/workers/pages_worker.rb +++ b/app/workers/pages_worker.rb @@ -9,7 +9,11 @@ def perform(action, *arg) def deploy(build_id) build = Ci::Build.find_by(id: build_id) - Projects::UpdatePagesService.new(build.project, build).execute + result = Projects::UpdatePagesService.new(build.project, build).execute + if result[:status] == :success + result = Projects::UpdatePagesConfigurationService.new(build.project).execute + end + result end def remove(namespace_path, project_path) diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index d33f1e736d35153552fac43d2b3dfcfbe66a5969..eeddcce2a6c1a063d5f9ebff7a4de8bde9dcd82f 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -299,6 +299,7 @@ def host(url) Settings.pages['port'] ||= Settings.pages.https ? 443 : 80 Settings.pages['protocol'] ||= Settings.pages.https ? "https" : "http" Settings.pages['url'] ||= Settings.send(:build_pages_url) +Settings.pages['custom_domain'] ||= false if Settings.pages['custom_domain'].nil? # # Git LFS diff --git a/config/routes.rb b/config/routes.rb index 3ba62cf715567d3660a9b61567c46aaeed82ab85..b98c6c99fff5e07e37827812775bd510c6487aaf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -428,7 +428,6 @@ delete :remove_fork post :archive post :unarchive - post :remove_pages post :housekeeping post :toggle_star post :markdown_preview @@ -547,6 +546,9 @@ end end + resource :pages, only: [:show, :update, :destroy] do + delete :certificates + end resources :compare, only: [:index, :create] resources :network, only: [:show], constraints: { id: /(?:[^.]|\.(?!json$))+/, format: /json/ } diff --git a/db/migrate/20160209125808_add_pages_custom_domain_to_projects.rb b/db/migrate/20160209125808_add_pages_custom_domain_to_projects.rb new file mode 100644 index 0000000000000000000000000000000000000000..6472199fc4a977fe1b92c5169672d5289443ad23 --- /dev/null +++ b/db/migrate/20160209125808_add_pages_custom_domain_to_projects.rb @@ -0,0 +1,10 @@ +class AddPagesCustomDomainToProjects < ActiveRecord::Migration + def change + add_column :projects, :pages_custom_certificate, :text + add_column :projects, :pages_custom_certificate_key, :text + add_column :projects, :pages_custom_certificate_key_iv, :string + add_column :projects, :pages_custom_certificate_key_salt, :string + add_column :projects, :pages_custom_domain, :string, unique: true + add_column :projects, :pages_redirect_http, :boolean, default: false, null: false + end +end