-
Notifications
You must be signed in to change notification settings - Fork 222
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fixes #23211 - Add PuppetCa TokenWhitelisting provider #592
Conversation
5bf0c2b
to
034eded
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How do we prevent concurrency issues with writing to the hosts file?
# Create a new token for a certname | ||
def autosign certname | ||
ensure_hostsfile | ||
payload = { certname: certname, exp: Time.now.to_i + 10 * 60 * 60 } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps this time should be configurable. IMHO at least defined as a constant so you know what this magic value is.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I agree. Do you think 10 hours is a good default?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Currently we allow 6 hours in foreman core as a default (for build tokens). I'd suggest do use the same.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If Foreman has a setting for this, should we have it as an API parameter instead? You could still apply a default there but that way you would have a single setting.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That would mean that the API would be different for the providers, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think an optional API parameter for the TTL would still be compatible enough. You may want to raise a 400 Bad Request on the old provider if a TTL is provided.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Raising an 400 on the old provider isn't a good idea, as foreman doesnt know which provider is used and will (if we add that) always include the TTL, meaning the old provider would not work. Will add the optional parameter.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's a fair point. #585 is my take at solving this but I don't see this being integrated any time soon.
payload = { certname: certname, exp: Time.now.to_i + 10 * 60 * 60 } | ||
token = JWT.encode payload, smartproxy_cert, 'RS512' | ||
File.write hosts_file, autosign_list.push(certname).to_yaml | ||
{ generated_token: token }.to_json |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should the JSON representation be done in this function? IMHO it's something that you do in the API class
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will move.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Moving the .to_json
to the API class would mean that we might change the behaviour of the old provider.
|
||
def ensure_hostsfile | ||
unless File.exist?(hosts_file) | ||
FileUtils.mkdir_p hosts_file.rpartition('/').first |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't the rpartition('/').first
equivalent to dirname()
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, sometimes I just remember the complicated ways to do thinks. Will change this.
plugin :puppetca_token_whitelisting, ::Proxy::VERSION | ||
|
||
requires :puppetca, ::Proxy::VERSION | ||
default_settings :sign_all => false, :hosts_file => '/tmp/foreman-proxy/hosts.yml' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't like this default because /tmp
is a shared directory. Another user on the system can create /tmp/foreman-proxy
prior to startup and have control. That makes it a security risk. We do have /var/lib/foreman-proxy
in some packages (notably REX/Ansible I think) and perhaps we should standardize this. An alternative is doing it in /etc/foreman-proxy
but I don't like it because it gives the impression users can/should modify it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We also weren't sure where to put the file. Its not code, it's not config, it does not belong in the puppet dir. I settled for this as it is a temporary file, but am not quite happy, so I'm open to suggestions. You think /var/lib/foreman-proxy/
would be the best idea?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/tmp
is wiped after a reboot. Losing that kind of state doesn't sound right for this file. /var/tmp
would be better but to me it sounds like this is actually a state file. For that /var/lib
is generally used.
include ::Proxy::Util | ||
|
||
def sign_all | ||
Proxy::PuppetCa::TokenWhitelisting::Plugin.settings.sign_all |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am still wondering if it should allow reading a whitelist from the actual autosign file but this might be enough.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think falling back to the autosign.conf
could be confusing. Allowing this for people who really really want to put the *
in the file should be fine.
def autosign certname | ||
ensure_hostsfile | ||
payload = { certname: certname, exp: Time.now.to_i + 10 * 60 * 60 } | ||
token = JWT.encode payload, smartproxy_cert, 'RS512' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the RS512
algorithm should be stored in a constant.
unless File.exist?(hosts_file) | ||
FileUtils.mkdir_p hosts_file.rpartition('/').first | ||
FileUtils.touch hosts_file | ||
File.write(hosts_file, [].to_yaml) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd move writing to this file to a (private) helper function rather than duplicate it in 3 places.
# List the hosts that are currently valid | ||
def autosign_list | ||
ensure_hostsfile | ||
YAML.load_file(hosts_file).to_a |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should safe_load
be used? Especially if the default location is in /tmp
there's a risk of executing arbitrary code as the foreman-proxy
user as long as you can write anything in /tmp
. Because of it I'd actually consider JSON rather than YAML. Since no user has to actually modify the file, it doesn't matter that it's not very human friendly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@juliantodt: Thanks, I left some comments inline.
modules/puppetca/puppetca_api.rb
Outdated
post "/validate" do | ||
content_type :json | ||
unless autosigner.respond_to?('validate_csr') | ||
log_halt 401, "Provider only supports trivial autosigning" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
401 is unauthorized, how about 406 or 412?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
406 would be incorrect because that's on Accept
headers. I'd consider HTTP 501 Not Implemented.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was thinking Bad Request
but that would have been 400. 501 would be fine for me.
return false | ||
end | ||
if sign_all | ||
logger.warn "Signing CSR without token verification." |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should be info I think.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We had that when we talked about the first SmartProxyPR #576 where witlessbird suggested warnings.
def disable certname | ||
hosts = autosign_list | ||
hosts.delete(certname) | ||
File.write hosts_file, hosts.to_yaml |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should extract this to a dedicated storage class.
end | ||
|
||
def smartproxy_cert | ||
OpenSSL::PKey::RSA.new File.read Proxy::SETTINGS.ssl_private_key.to_s |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What happens if the smart proxy is run without ssl?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, that would be a problem. I guess we would have to ensure that there is a RSA certificate there if used for SSL or not...
|
||
# Invalidate a token based on the certname | ||
def disable certname | ||
hosts = autosign_list |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe we should store the (in)valid tokens here instead of the certnames. If a token is lost, a user has no way of actually revoking it for good.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed, I think storing valid tokens is the way to go. It allows us to do everything we do now (since we can always extract the certname from the tokens) while we still don't have to clean up periodically because the list would auto-clean itself + additionally we can revoke specific tokens manually.
end | ||
@autosigner = Proxy::PuppetCa::TokenWhitelisting::Autosigner.new | ||
@autosigner.stubs(:hosts_file).returns(@file.path) | ||
rsa_cert = OpenSSL::PKey::RSA.generate 2048 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this mean a RSA cert is generated for every test in this file? Isn't that somewhat slow?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not noticeable (at least for me). But since we decided to change the implementation to save the tokens instead of certnames, we will need to supply a fixtured cert anyway.
034eded
to
f5636ed
Compare
Updated:
TODO:
|
0e2cdf4
to
289d77f
Compare
Updated. This should now satisfy all your concerns. Thanks @ekohl @timogoebel! Tests are now green as well. Question: If we decide to add the token ttl to the foreman api call, we would break compatibility with old SmartProxy versions as they would not recognise the url, right? |
I think we can live with that. We could check the smart proxy version in foreman and add a switch for that. Or just try again if we get a 404. @ekohl: WDYT? |
modules/puppetca/puppetca_api.rb
Outdated
content_type :json | ||
certname = params[:certname] | ||
certname = params['captures'][0] | ||
ttl = params['captures'][2] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't the TTL more of a body parameter in REST?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, we could make this a parameter and not part of the URL. That would make this even more backward compatible.
rescue JWT::ExpiredSignature | ||
nil | ||
end | ||
end.reject { |certname| certname.nil? } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't this equal reject equal to compact
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@juliantodt: Yet another round of comments. :-)
@@ -0,0 +1,7 @@ | |||
group :puppetca_token_whitelisting do | |||
if RUBY_VERSION < '2.1' | |||
gem 'jwt', '1.5.6' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we use ~> 1.5.6
?
modules/puppetca/puppetca_api.rb
Outdated
rescue => e | ||
log_halt 406, "Failed to enable autosign for #{certname}: #{e}" | ||
end | ||
end | ||
|
||
post "/validate" do | ||
content_type :json | ||
unless autosigner.respond_to?('validate_csr') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: I'd us a symbol here: respond_to?(:validate_csr)
end | ||
|
||
def ensure_file | ||
unless File.exist?(@tokens_file) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you can use a guard clause here, return of File.exists?(@token_file)
end | ||
end | ||
|
||
def locked_write content |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the method name is misleading, as the write is not actually locked here.
289d77f
to
2ea6d64
Compare
Updated based on your comments, thank you! |
|
||
file = Proxy::PuppetCa::TokenWhitelisting::Plugin.settings.certificate | ||
unless File.exist?(file) | ||
File.write file, OpenSSL::PKey::RSA.generate(2048) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's probably good to do this with a umask so it's not world readable (or some other way). The number of bits should also be at least a constant IMHO.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added.
end | ||
end | ||
|
||
def unsave_write content |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
unsafe?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
-.-
true | ||
end | ||
end | ||
storage.unsave_write tokens |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any reason this should be unsafe?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The whole block is already locked so nothing changes the file between read and write. You would not be able to safely write here, as that would try to lock the file again which is already locked.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if the storage should have an operation where you pass in a block to delete tokens. It feels a bit like you're doing storage operations in the signer here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added a TokenStorage::remove_if
method, take a look.
2ea6d64
to
dde2024
Compare
dde2024
to
ac7198a
Compare
ac7198a
to
ef0e7bb
Compare
As requested, I moved the puppet_sign script from the Puppet-Foreman_Proxy PR to here and added a couple of improvements. |
ef0e7bb
to
9e4a9ec
Compare
@ekohl: Any further comments? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall it looks good and I'd be OK with merging. Just small edge cases you might want to look at.
extra/puppet_sign.rb
Outdated
end | ||
SETTINGS = YAML.load_file(settings_file) | ||
PUPPETCA = YAML.load_file(SETTINGS[:settings_directory] + '/puppetca.yml') | ||
protocol = PUPPETCA[:enabled] == true ? 'https' : PUPPETCA[:enabled] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably good if this also handles the value false
and print a proper error
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added.
end | ||
begin | ||
request.body.rewind | ||
autosigner.validate_csr(request.body.read) ? 200 : 404 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm wondering about the 404 code; will there be a need to distinguish between a proxy without this feature and one with? Probably not but it might be worth to consider 400 Bad Request for invalid CSRs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well the 404 here only gets returned when the CSR is valid but the token is not. A proxy without this feature returns 501 (line 43) and an invalid CSR raises an Exception leading to a 406 (line 49). Does that work for you?
9e4a9ec
to
a7ac112
Compare
@ekohl: Final ACK? You still "request changes" on this PR. :-) |
Thanks, @juliantodt. |
This adds an alternative PuppetCa provider.
Instead of signing CSRs based on whitelist of hosts (directly via puppet), we will create a signed JWT and return it to foreman that will provision the token on the host so that it is included in the CSR the hosts sends to the PuppetCa. The PuppetCa will then use a script (rather than the textfile) (see puppet-foreman_proxy) that calls the SmartProxy with the CSR that will then validate the token inside it. This should be much more secure and easier to debug than the current hostname_whitelisting provider.
Note: For this to work, you will need #5730 in your foreman instance.