diff --git a/lib/puppet/functions/generate_mac.rb b/lib/puppet/functions/generate_mac.rb new file mode 100644 index 000000000..f13d2a01d --- /dev/null +++ b/lib/puppet/functions/generate_mac.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'digest' + +# generates a deterministic pseudorandom mac address, using hostname as seed +# +# prefix validated to follow the following parameters: +# - must contain exactly 6 hex digits +# - second digit must be one of [2,6,a,e] (per RFC7042 ยง2.1, see "Local bit") +# - case is ignored +# - may contain unlimited number of non-alphanumerics as delimeters +# +# generated address contains +# - 3 byte fixed OUI (prefix) +# - 21 "random" bits (seeded from hostname) +# - 3 bit sequential number (index) +Puppet::Functions.create_function(:generate_mac) do + dispatch :generate_mac do + param "String", :prefix + param "String", :hostname + param "Integer", :index + end + + def generate_mac(prefix, hostname, index) + unless(index.between?(0,7)) + raise(ArgumentError, "#{index} must be between 0 and 7, I can only generate 8 mac addresses per host!") + end + + oui = prefix.downcase.gsub(/[^0-9a-z]/,'') + unless(oui =~ /^[a-f0-9][26ae][a-f0-9]{4}$/) + raise(ArgumentError, "invalid mac prefix!") + end + + integer_id = (Digest::SHA256.hexdigest(hostname)[0,6].to_i(16) & 0xFF_FF_F8) + index + hex_id = sprintf('%06x',integer_id) + "#{oui}#{hex_id}".each_char.each_slice(2).map{|x| x.join}.join(':') + end +end diff --git a/spec/functions/generate_mac_spec.rb b/spec/functions/generate_mac_spec.rb new file mode 100644 index 000000000..c46ffe629 --- /dev/null +++ b/spec/functions/generate_mac_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# Copyright (c) 2020 The Regents of the University of Michigan. +# All Rights Reserved. Licensed according to the terms of the Revised +# BSD License. See LICENSE.txt for details. +require "spec_helper" + +describe "generate_mac" do + it "generates simple addresses" do + is_expected.to run.with_params('02:00:00', 'example.com', 0).and_return('02:00:00:a3:79:a0') + is_expected.to run.with_params('12-0f-00', 'example.com', 2).and_return('12:0f:00:a3:79:a2') + end + it "fails on invalid chars" do + is_expected.to run.with_params('g2:00:00', 'example.com', 0).and_raise_error(ArgumentError) + end + it "correctly masks final 3 bits" do + is_expected.to run.with_params('06 12 34', 'my.host.name', 0).and_return('06:12:34:3b:d9:98') + is_expected.to run.with_params('5a.67.89', 'my.host.name', 3).and_return('5a:67:89:3b:d9:9b') + is_expected.to run.with_params('AE:BC:DE', 'my.host.name', 7).and_return('ae:bc:de:3b:d9:9f') + end + it "fails on non-private oui" do + is_expected.to run.with_params('03:00:00', 'example.com', 0).and_raise_error(ArgumentError) + end + it "fails on out of range index" do + is_expected.to run.with_params('02:00:00', 'example.com', 11).and_raise_error(ArgumentError) + is_expected.to run.with_params('02:00:00', 'example.com', -1).and_raise_error(ArgumentError) + end +end +