diff --git a/REFERENCE.md b/REFERENCE.md index a81e2a6e..2039e8f3 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -6,6 +6,8 @@ ### Classes +#### Public Classes + * [`gitlab`](#gitlab): This module installs and configures Gitlab with the Omnibus package. * [`gitlab::backup`](#gitlab--backup): This class is called from gitlab for backup config. * [`gitlab::host_config`](#gitlab--host_config): This class is for setting host configurations required for gitlab installation. @@ -14,6 +16,10 @@ * [`gitlab::omnibus_package_repository`](#gitlab--omnibus_package_repository): This class is used to configure gitlab repositories * [`gitlab::service`](#gitlab--service): This class is meant to be called from gitlab. It ensure the service is running. +#### Private Classes + +* `gitlab::initial_root_token`: Manages initial root token + ### Defined types * [`gitlab::custom_hook`](#gitlab--custom_hook): Manage custom hook files within a GitLab project. @@ -129,6 +135,9 @@ The following parameters are available in the `gitlab` class: * [`pgpass_file_location`](#-gitlab--pgpass_file_location) * [`pgpass_file_ensure`](#-gitlab--pgpass_file_ensure) * [`pgbouncer_password`](#-gitlab--pgbouncer_password) +* [`create_initial_root_token`](#-gitlab--create_initial_root_token) +* [`initial_root_token`](#-gitlab--initial_root_token) +* [`initial_root_token_ttl_minutes`](#-gitlab--initial_root_token_ttl_minutes) * [`consul`](#-gitlab--consul) * [`custom_hooks_dir`](#-gitlab--custom_hooks_dir) * [`system_hooks_dir`](#-gitlab--system_hooks_dir) @@ -919,6 +928,30 @@ Password for the gitlab-consul database user in the pgbouncer database Default value: `undef` +##### `create_initial_root_token` + +Data type: `Boolean` + +Whether to create an initial root token. If set to true and initial_root_token is undef, a random token string will be generated. + +Default value: `false` + +##### `initial_root_token` + +Data type: `Optional[Sensitive[String[1]]]` + +Preset a root token to allow API usage immediately. + +Default value: `undef` + +##### `initial_root_token_ttl_minutes` + +Data type: `Integer[0]` + +Initial root token time to live (in minutes). + +Default value: `60` + ##### `consul` Data type: `Optional[Hash]` diff --git a/files/gitlab_api_token_renewer.rb b/files/gitlab_api_token_renewer.rb new file mode 100644 index 00000000..e3fbfd6a --- /dev/null +++ b/files/gitlab_api_token_renewer.rb @@ -0,0 +1,105 @@ +#!/usr/bin/env ruby + +require 'net/http' +require 'json' +require 'time' +require 'uri' +require 'tempfile' + +class GitlabApiTokenRenewer + def initialize + @api_url = ENV.fetch('GITLAB_API_URL', 'http://localhost') + @token_file = ENV.fetch('GITLAB_API_TOKEN_FILE', '/var/opt/gitlab/.tokens/puppet_token') + @token_renew_days = ENV.fetch('GITLAB_API_TOKEN_RENEW_DAYS', '7').to_i + @new_token_ttl_days = ENV.fetch('GITLAB_API_NEW_TOKEN_TTL_DAYS', '30').to_i + @token = File.read(@token_file).strip + + uri = URI(@api_url) + @http = Net::HTTP.new(uri.host, uri.port) + @http.use_ssl = uri.scheme == 'https' + end + + def write_token + f = Tempfile.create('.tkn', File.dirname(@token_file)) + f.write(token) + f.flush + f.close + File.rename(f, @token_file) + end + + def api_request(method, endpoint, body = nil) + request_class = case method.downcase + when :get then Net::HTTP::Get + when :post then Net::HTTP::Post + else raise "Unsupported HTTP method" + end + + request = request_class.new(uri) + request['Authorization'] = "Bearer #{token}" + request['Content-Type'] = 'application/json' if body + request.body = body.to_json if body + + @http.request(request) + end + + def get_current_token_info + response = api_request(:get, 'personal_access_tokens/self') + + case response + when Net::HTTPSuccess + JSON.parse(response.body) + when Net::HTTPUnauthorized + abort "Token is invalid, revoked, or expired." + else + abort "Failed to get token info: #{response.code} #{response.body}" + end + end + + def rotate_current_token(new_expiry = nil) + payload = {} + payload[:expires_at] = new_expiry if new_expiry + + response = api_request(:post, 'personal_access_tokens/self/rotate', payload) + + case response + when Net::HTTPSuccess + @token = JSON.parse(response.body)['token'] + when Net::HTTPUnauthorized + abort "Token cannot be rotated (revoked, expired, or invalid)." + when Net::HTTPForbidden + abort "Token lacks permission to rotate (needs 'api' or 'self_rotate' scope)." + else + abort "Rotation failed: #{response.code} #{response.body}" + end + end + + def run + info = get_current_token_info + expires_at_str = info['expires_at'] + + if expires_at_str.nil? + warn "Token has no expiration." + else + expires_at = Time.parse(expires_at_str).utc + threshold = Time.now.utc + (@token_renew_days * 86400) + if expires_at > threshold + puts "Token expires on #{expires_at}, still valid. No rotation needed." + exit 0 + end + puts "Token expires on #{expires_at}, rotating..." + end + + new_expiry = (Time.now + 30 * 24 * 60 * 60).strftime('%Y-%m-%d') + rotate_current_token(new_expiry) + puts "Token rotated in GitLab." + + write_token + puts "New token written to #{@token_file}." + + puts "Rotation complete." + end +end + +if __FILE__ == $0 + GitlabApiTokenRenewer.new.run +end diff --git a/manifests/init.pp b/manifests/init.pp index 6d526268..47a3603e 100644 --- a/manifests/init.pp +++ b/manifests/init.pp @@ -117,7 +117,7 @@ # @param backup_cron_minute The minute when to run the daily backup cron job # @param backup_cron_hour The hour when to run the daily backup cron job # @param backup_cron_skips Array of items to skip valid values: db, uploads, repositories, builds, artifacts, lfs, registry, pages -# @param package_hold Wether to hold the specified package version. Available options are 'hold' or 'none'. Defaults to 'none'. Available only for Debian/Solaris package managers. +# @param package_hold Wether to hold the specified package version. Available options are 'hold' or 'none'. Defaults to 'none'. Available only for Debian/Solaris package managers. # @param package_name The internal packaging system's name for the package. This name will automatically be changed by the gitlab::edition parameter. Can be overridden for the purposes of installing custom compiled version of gitlab-omnibus. # @param manage_package Should the GitLab package be managed? # @param repository_configuration A hash of repository types and attributes for configuraiton the gitlab package repositories. See docs in README.md @@ -125,6 +125,9 @@ # @param pgpass_file_location Path to location of .pgpass file used by consul to authenticate with pgbouncer database # @param pgpass_file_ensure Create .pgpass file for pgbouncer authentication. When set to present requires valid value for pgbouncer_password. # @param pgbouncer_password Password for the gitlab-consul database user in the pgbouncer database +# @param manage_api_token Whether to manage the API token. This token belongs to the root Gitlab user. +# @param api_token_dir Where to store the API token generated. +# @param api_token_ttl_days API token time to live in days. class gitlab ( Hash $repository_configuration, # package configuration @@ -224,6 +227,9 @@ Optional[Hash] $gitlab_workhorse = undef, Optional[Hash] $user = undef, Optional[Hash] $web_server = undef, + Boolean $manage_api_token = false, + Stdlib::Absolutepath $api_token_file = '/var/opt/gitlab/.tokens/puppet_token', + Integer[0] $api_token_ttl_days = 30, Boolean $backup_cron_enable = false, Integer[0,59] $backup_cron_minute = 0, Integer[0,23] $backup_cron_hour = 2, @@ -238,11 +244,13 @@ contain gitlab::omnibus_config contain gitlab::install contain gitlab::service + contain gitlab::initial_root_token Class['gitlab::host_config'] -> Class['gitlab::omnibus_config'] -> Class['gitlab::install'] -> Class['gitlab::service'] + -> Class['gitlab::initial_root_token'] $custom_hooks.each |$name, $options| { gitlab::custom_hook { $name: diff --git a/manifests/initial_root_token.pp b/manifests/initial_root_token.pp new file mode 100644 index 00000000..79992768 --- /dev/null +++ b/manifests/initial_root_token.pp @@ -0,0 +1,49 @@ +# @summary Manages initial root token +# +# **NOTE** This hack allows to use the gitlab instance via API immediately. +# While this way is quite convenient, it cannot be called a good one.. +# Use it at your own risk! +# +# Remove the /etc/gitlab/initial_root_token file to regenerate the token in a +# next Puppet run. +# +# @see https://docs.gitlab.com/administration/operations/rails_console/#using-the-rails-runner +# @see https://docs.gitlab.com/user/profile/personal_access_tokens/#create-a-personal-access-token-programmatically +# +# @api private +class gitlab::initial_root_token { + $api_token_file = $gitlab::api_token_file + $script_path = '/etc/gitlab/create_initial_root_token.rb' + + if $gitlab::create_initial_root_token { + $script_ensure = 'file' + $script_content = epp('gitlab/create_initial_root_token.rb.epp', + token => $gitlab::initial_root_token, + token_ttl_minutes => $gitlab::initial_root_token_ttl_minutes, + token_file_path => $token_file_path, + ) + + # Execute after the script is created, but only if token is managed + exec { 'create_initial_root_token': + command => "/usr/bin/gitlab-rails runner '${script_path}'", + creates => $token_file_path, + require => File[$script_path], + } + } else { + $script_ensure = 'absent' + $script_content = undef + + # Ensure there is no token file left if it was created before + file { $token_file_path: + ensure => 'absent', + } + } + + file { $script_path: + ensure => $script_ensure, + owner => 'root', + group => 'git', # gitlab-rails runner executes this script as 'git' user + mode => '0640', + content => $script_content, + } +} diff --git a/spec/classes/init_spec.rb b/spec/classes/init_spec.rb index e5637c4e..bef2d9bd 100644 --- a/spec/classes/init_spec.rb +++ b/spec/classes/init_spec.rb @@ -11,7 +11,11 @@ it { is_expected.to contain_class('gitlab::host_config').that_comes_before('Class[gitlab::install]') } it { is_expected.to contain_class('gitlab::omnibus_config').that_comes_before('Class[gitlab::install]') } it { is_expected.to contain_class('gitlab::install').that_comes_before('Class[gitlab::service]') } - it { is_expected.to contain_class('gitlab::service') } + it { is_expected.to contain_class('gitlab::service').that_comes_before('Class[gitlab::initial_root_token]') } + it { is_expected.to contain_class('gitlab::initial_root_token') } + it { is_expected.to contain_file('/etc/gitlab/create_initial_root_token.rb').with_ensure('absent') } + it { is_expected.to contain_file('/etc/gitlab/initial_root_token').with_ensure('absent') } + it { is_expected.not_to contain_exec('create_initial_root_token') } it { is_expected.to contain_exec('gitlab_reconfigure').that_subscribes_to('Class[gitlab::omnibus_config]') } it { is_expected.to contain_file('/etc/gitlab/gitlab.rb') } it { is_expected.to contain_package('gitlab-omnibus').with_ensure('installed').with_name('gitlab-ce') } @@ -511,6 +515,30 @@ is_expected.to contain_package('gitlab-omnibus').with('ensure' => '16.10.3-ce.0', 'name' => 'gitlab-ce', 'mark' => 'hold') } end + describe 'create_intial_root_token' do + let(:params) { { create_initial_root_token: true } } + + it do + is_expected.to contain_file('/etc/gitlab/create_initial_root_token.rb'). + with_content(%r{^token = nil$}). + with_content(%r{^token_ttl_minutes = 60$}). + with_content(%r{^token_file_path = '/etc/gitlab/initial_root_token'$}) + end + it { is_expected.to contain_exec('create_initial_root_token').with_creates('/etc/gitlab/initial_root_token') } + it { is_expected.not_to contain_file('/etc/gitlab/initial_root_token') } # This file is managed only if create_initial_root_token is false + + describe 'initial_root_token' do + let(:params) { super().merge(initial_root_token: sensitive('foobarbaz')) } + + it { is_expected.to contain_file('/etc/gitlab/create_initial_root_token.rb').with_content(%r{^token = 'foobarbaz'$}) } + end + + describe 'initial_root_token_ttl_minutes' do + let(:params) { super().merge(initial_root_token_ttl_minutes: 123) } + + it { is_expected.to contain_file('/etc/gitlab/create_initial_root_token.rb').with_content(%r{^token_ttl_minutes = 123$}) } + end + end end end end diff --git a/templates/create_initial_root_token.rb.epp b/templates/create_initial_root_token.rb.epp new file mode 100644 index 00000000..f0c62217 --- /dev/null +++ b/templates/create_initial_root_token.rb.epp @@ -0,0 +1,24 @@ +<%-| + Optional[Sensitive[String[1]]] $token, + Integer[0] $token_ttl_minutes, + Stdlib::AbsolutePath $token_file_path, +|-%> +# This script should be executed with 'gitlab-rails runner' command. +# +# This scripts creates an initial root token and stores it to the +# <%= $token_file_path %> file. +token = <%= $token.then |$x| { "'${$x.unwrap}'" }.lest || { nil } %> +token_ttl_minutes = <%= $token_ttl_minutes %> +token_file_path = '<%= $token_file_path %>' + +require 'securerandom' +token_value = token || 'glpat-' + SecureRandom.alphanumeric(20) + +t = User.find(1).personal_access_tokens.create( + scopes: [:api], + name: 'Gitlab Puppet module initial root token', + expires_at: token_ttl_minutes.minutes.from_now, +) +t.set_token(token_value) +t.save! +File.write(token_file_path, token_value, perm: 0600)