setup: carry the requester's login as the username
Every profile said alpha@example.com, no matter who it was for;
now /setup writes the asker's login, and rake a placeholder, nothing more.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/Rakefile b/Rakefile
index 419f592..aa19bed 100644
--- a/Rakefile
+++ b/Rakefile
@@ -75,6 +75,10 @@ end
desc "Render the macOS configuration profile (carddav.mobileconfig)"
task profile: "carddav.mobileconfig"
+# The server ignores the username (see ProTacts::Profile.render), and a
+# profile rendered here has no request to read a login from.
+PROFILE_USERNAME = "pro-tacts@example.com"
+
# Rebuilds when the template changes but not when the environment does
# (PRO_TACTS_HOSTNAME, PRO_TACTS_PROFILE_NAME); delete carddav.mobileconfig
# to force a rerender.
@@ -82,7 +86,7 @@ file "carddav.mobileconfig" => "lib/pro_tacts/profile.rb" do |task|
require "pro_tacts/profile"
File.write(task.name, ProTacts::Profile.render(
- hostname: ENV.fetch("PRO_TACTS_HOSTNAME")
+ hostname: ENV.fetch("PRO_TACTS_HOSTNAME"), username: PROFILE_USERNAME
))
end
@@ -93,7 +97,7 @@ namespace :profile do
require "pro_tacts/profile"
File.write("carddav.mobileconfig", ProTacts::Profile.render(
- hostname: ENV.fetch("PRO_TACTS_HOSTNAME")
+ hostname: ENV.fetch("PRO_TACTS_HOSTNAME"), username: PROFILE_USERNAME
))
sh "open", "carddav.mobileconfig"
sh "open", "x-apple.systempreferences:com.apple.preferences.configurationprofiles"
diff --git a/lib/pro_tacts/profile.rb b/lib/pro_tacts/profile.rb
index 69fcf71..afc52d8 100644
--- a/lib/pro_tacts/profile.rb
+++ b/lib/pro_tacts/profile.rb
@@ -1,4 +1,3 @@
-
require "securerandom"
require "nokogiri"
@@ -45,17 +44,19 @@ module ProTacts
ProTacts.config.profile_name || DEFAULT_NAME
end
- # The username and password are a throwaway fictional pair and the server
- # ignores them: identity comes from the Tailscale headers that serve
- # injects (see ProTacts::TailscaleAuth). They stay in the template
- # because the account form expects the fields; dropping them is
- # untested.
- #: (hostname: String) -> String
- def self.render(hostname:)
+ # The server ignores the username and password: identity comes from
+ # the Tailscale headers that serve injects (see
+ # ProTacts::TailscaleAuth). /setup passes the requester's login
+ # anyway, so the account says which tailnet user it is for. The
+ # password stays a placeholder because the account form expects the
+ # field, and dropping it is untested.
+ #: (hostname: String, username: String) -> String
+ def self.render(hostname:, username:)
identifier = "#{IDENTIFIER_PREFIX}-#{unique_hex}"
template % {
hostname: escape(hostname),
+ username: escape(username),
name: escape(account_name),
identifier:,
account_identifier: "#{identifier}.account",
@@ -103,7 +104,7 @@ module ProTacts
<key>CardDAVHostName</key>
<string>%{hostname}</string>
<key>CardDAVUsername</key>
- <string>alpha@example.com</string>
+ <string>%{username}</string>
<key>CardDAVPassword</key>
<string>carddav-dev</string>
<key>CardDAVUseSSL</key>
diff --git a/lib/pro_tacts/web/setup.rb b/lib/pro_tacts/web/setup.rb
index 759dc03..219f291 100644
--- a/lib/pro_tacts/web/setup.rb
+++ b/lib/pro_tacts/web/setup.rb
@@ -35,7 +35,7 @@ module ProTacts
# is the point.
r.get "carddav.mobileconfig" do
response["Content-Type"] = "application/x-apple-aspen-config"
- Profile.render(hostname: r.host)
+ Profile.render(hostname: r.host, username: @identity.login)
end
end
end
diff --git a/test/pro_tacts/admin/test_device_setup.rb b/test/pro_tacts/admin/test_device_setup.rb
index 92e82ef..3e5cef3 100644
--- a/test/pro_tacts/admin/test_device_setup.rb
+++ b/test/pro_tacts/admin/test_device_setup.rb
@@ -79,6 +79,13 @@ class AdminDeviceSetupTest < Minitest::Test
assert_includes last_response.body, "<string>other.example.ts.net</string>"
end
+ def test_document_names_the_requester_as_the_username
+ header "Tailscale-User-Login", "zoe@example.com"
+ get "/setup/carddav.mobileconfig"
+
+ assert_match(%r{<key>CardDAVUsername</key>\s*<string>zoe@example.com</string>}, last_response.body)
+ end
+
# Fresh per download (see Profile): a reinstall is a cold account, not
# an update of the one already there.
def test_each_download_carries_its_own_identifier
diff --git a/test/pro_tacts/test_profile.rb b/test/pro_tacts/test_profile.rb
index 41f7839..df91453 100644
--- a/test/pro_tacts/test_profile.rb
+++ b/test/pro_tacts/test_profile.rb
@@ -4,8 +4,8 @@ require_relative "../test_helper"
require "pro_tacts/profile"
class ProfileTest < Minitest::Test
- def render(hostname: "example.ts.net")
- ProTacts::Profile.render(hostname:)
+ def render(hostname: "example.ts.net", username: "ada@example.com")
+ ProTacts::Profile.render(hostname:, username:)
end
# The name comes from the environment now rather than an argument, so
@@ -27,11 +27,11 @@ class ProfileTest < Minitest::Test
assert_includes render, "<string>com.apple.carddav.account</string>"
end
- def test_embeds_hostname_and_fixed_dev_credentials
+ def test_embeds_hostname_username_and_placeholder_password
xml = render
assert_includes xml, "<string>example.ts.net</string>"
- assert_includes xml, "<string>alpha@example.com</string>"
+ assert_match(%r{<key>CardDAVUsername</key>\s*<string>ada@example.com</string>}, xml)
assert_includes xml, "<string>carddav-dev</string>"
end
@@ -131,10 +131,11 @@ class ProfileTest < Minitest::Test
end
def test_escapes_xml_in_field_values
- xml = render(hostname: "a&b.ts.net")
+ xml = render(hostname: "a&b.ts.net", username: "Ada <& Co>")
assert_includes xml, "<string>a&b.ts.net</string>"
refute_includes xml, "<string>a&b.ts.net</string>"
+ assert_includes xml, "<string>Ada <& Co></string>"
assert_empty Nokogiri::XML(xml).errors
end
end