ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/AnyEvent-MCP/lib/AnyEvent/MCP/Config.pm
Revision: 1.5
Committed: Sun Oct 11 00:53:55 2026 UTC (4 weeks, 4 days ago) by root
Branch: MAIN
Changes since 1.4: +21 -14 lines
Log Message:
*** empty log message ***

File Contents

# User Rev Content
1 root 1.1 =head1 NAME
2    
3     AnyEvent::MCP::Config - named servers and derived MCP credentials
4    
5     =head1 SYNOPSIS
6    
7     my $config = AnyEvent::MCP::Config->global;
8 root 1.5 my $token = $config->credential ("cookie", "agni");
9 root 1.1 my $url = $config->url ("agni");
10    
11     =head1 DESCRIPTION
12    
13 root 1.3 An MCP server and its clients often need matching credentials. Rather than
14     maintaining a separate password for each server and user, this module can
15     derive them from one private master secret. A server name, optional username
16     and credential purpose each contribute to the result. A password derived for
17     one server is therefore not the password for another.
18    
19     The shared file format is described in L<AnyEvent::MCP/GLOBAL CONFIGURATION>.
20     Loading this module does not read any files. The first call to C<global>
21     reads the configuration, ensures a master secret exists and caches the object
22     for the process.
23 root 1.1
24     A missing secret is generated using C<AnyEvent::MCP::random_bytes> and saved
25     when possible. Saving failure produces a warning; the generated secret remains
26 root 1.3 usable in this process, but another process might derive different credentials.
27     A supplied invalid secret is an error. Changing or losing the master secret
28     invalidates all credentials derived from it.
29 root 1.1
30     Derivation uses HKDF-SHA512 expansion with the random master key as its PRK.
31     Server name, username and credential purpose are separately length-prefixed,
32     so credentials for different servers, users or authentication methods differ.
33 root 1.3 Deriving a password does not create an account or configure the server to
34     accept it: authentication still belongs to the server.
35 root 1.1
36     =head1 METHODS
37    
38     =head2 $config = AnyEvent::MCP::Config->global (file => $file)
39    
40     Returns the cached global configuration. The first call accepts the same
41     arguments as C<new>; later calls return the same object without rereading
42     the file. Call this explicitly during program startup.
43    
44     =head2 $config = AnyEvent::MCP::Config->new (file => $file)
45    
46     Reads C<$file>, or the default file when omitted, and ensures a master
47     secret exists. Unlike C<global>, each call creates an independent object.
48     A missing file is allowed; other read errors are fatal. Sections are available
49     as C<< $config->{section}{$section} >>, each a hash reference of string
50     values. Named servers use C<[server NAME]>.
51    
52     =head2 $config = AnyEvent::MCP::Config->init (file => $file)
53    
54     Explicitly creates the configuration file with a random secret, mode 0600.
55     Creates missing parent directories with mode 0700. Fails if the file already
56     exists, so it cannot accidentally replace an existing secret.
57    
58     =head2 $credential = $config->credential ($purpose, $name, $username)
59    
60     Derives a 32-byte credential, returned as unpadded URL-safe Base64.
61 root 1.5 C<$purpose> is C<cookie> (the default server token) or C<bearer> (a separate
62     derivation domain). C<$name> is the server name. C<$username> is optional.
63 root 1.1
64     This method does not require a corresponding server section.
65    
66     =head2 $url = $config->url ($name, $username)
67    
68 root 1.5 Returns the configured C<url> for C<$name>, replacing C<{token}> or
69     C<{cookie}> with the first configured token, or the derived server token
70     when no static tokens are configured. C<$username> optionally derives a
71     separate credential instead.
72 root 1.1
73     The template supplies scheme, host, port, path and cookie placement explicitly.
74     This method neither contacts a server nor infers an ephemeral listening port.
75     The HTTP server does not consume this configuration automatically.
76    
77 root 1.4 =head2 $name = AnyEvent::MCP::Config::server_name ($name)
78    
79     Validate a service-instance name, or use the program basename if undefined.
80     Names consist of an ASCII letter or underscore followed by letters, digits,
81     underscores or hyphens.
82    
83 root 1.5 =head2 @options = AnyEvent::MCP::Config::http_getopt (\$name, \$bind, \$print_url, \@tokens)
84 root 1.4
85     Return standard C<Getopt::Long> options referring to the caller's scalars.
86    
87     =head2 $port = $config->server_port ($name)
88    
89     Derive a stable default port in 16384..32767 from the master secret and
90     service-instance name. Collisions produce ordinary listener bind errors.
91    
92     =head2 %settings = $config->http_settings ($name, $bind)
93    
94     Resolve HTTP settings from C<[server NAME]> and an optional command-line
95     C<bind> override. The default is IPv4 loopback and the name-derived port.
96     The bind setting accepts C<ADDRESS:PORT>, C<[IPv6]:PORT>, C<ADDRESS>,
97     and C<:PORT> (which keeps the default loopback address).
98     Hostnames are resolved once to a numeric address at startup; their original
99     spelling is advertised unless C<url_host> overrides it.
100    
101     =head2 $url = $config->http_url ($name, $bind, %options)
102    
103 root 1.5 Format a URL containing the first accepted token, without listening. Port zero cannot be used
104 root 1.4 because the kernel has not yet selected a port.
105    
106 root 1.1 =cut
107    
108     package AnyEvent::MCP::Config;
109    
110     use common::sense;
111     use AnyEvent::MCP ();
112     use MIME::Base64 ();
113     use File::Basename ();
114     use File::Path ();
115     use Fcntl qw(O_WRONLY O_RDWR O_CREAT O_EXCL LOCK_EX);
116     use Errno qw(ENOENT);
117 root 1.4 use AnyEvent::Socket ();
118     use Socket ();
119 root 1.1
120     our @ISA = qw(AnyEvent::MCP::Base);
121    
122     sub _file {
123     my $dir = $ENV{XDG_CONFIG_HOME}
124     || ($ENV{HOME} || die "HOME is not set\n") . "/.config";
125    
126     "$dir/anyevent-mcp/global.ini"
127     }
128    
129     my $global;
130    
131     sub global {
132     my $class = shift;
133    
134     $global ||= $class->new (@_)
135     }
136    
137     sub new {
138     my ($class, %kv) = @_;
139     my $self = $class->SUPER::new (section => {}, %kv);
140     $self->{file} = _file unless exists $self->{file};
141    
142     if (open my $fh, "<:encoding(UTF-8)", $self->{file}) {
143     $self->_read ($fh);
144     close $fh or die "$self->{file}: $!\n";
145     } else {
146     die "$self->{file}: $!\n" unless $! == ENOENT;
147     }
148    
149     $self->_ensure_secret;
150    
151     die "$self->{file}: invalid secret; supply 128 hexadecimal digits\n"
152     unless $self->{section}{global}{secret} =~ /\A[0-9a-fA-F]{128}\z/;
153    
154     $self
155     }
156    
157     sub _read {
158     my ($self, $fh) = @_;
159    
160     my @stat = stat $fh;
161     die "$self->{file}: configuration must be private and owned by this user\n"
162     if $stat[4] != $< || $stat[2] & 077;
163    
164     my $section = $self->{section}{global} ||= {};
165    
166     while (<$fh>) {
167     next if /\A\s*(?:[#;]|\z)/;
168    
169     if (/\A\s*\[([^\]\r\n]+)\]\s*\z/) {
170     $section = $self->{section}{$1} ||= {};
171     } elsif (/\A\s*([^=\s]+)\s*=\s*(.*?)\s*\z/) {
172     $section->{$1} = $2;
173     } else {
174     die "$self->{file}:$.: invalid configuration line\n";
175     }
176     }
177    
178     }
179    
180     sub _ensure_secret {
181     my ($self) = @_;
182    
183     return if exists $self->{section}{global}{secret};
184    
185     my $secret = eval { unpack "H*", AnyEvent::MCP::random_bytes 64 };
186     die "$@Supply a securely generated 128-digit hexadecimal secret in $self->{file}\n"
187     if $@;
188    
189     eval {
190     File::Path::mkpath (File::Basename::dirname ($self->{file}), 0, 0700);
191     sysopen my $fh, $self->{file}, O_RDWR | O_CREAT, 0600
192     or die "$self->{file}: $!\n";
193     flock $fh, LOCK_EX or die "$self->{file}: $!\n";
194     binmode $fh, ":encoding(UTF-8)";
195    
196     my $disk = bless { file => $self->{file}, section => {} }, ref $self;
197     $disk->_read ($fh);
198    
199     unless (exists $disk->{section}{global}{secret}) {
200     seek $fh, 0, 2 or die "$self->{file}: $!\n";
201     print {$fh} "\n[global]\nsecret = $secret\n"
202     or die "$self->{file}: $!\n";
203     $disk->{section}{global}{secret} = $secret;
204     }
205    
206     close $fh or die "$self->{file}: $!\n";
207     $self->{section} = $disk->{section};
208     1
209     } or warn "Cannot save MCP master secret; credentials may only be stable for this process: $@";
210    
211     $self->{section}{global}{secret} = $secret
212     unless exists $self->{section}{global}{secret};
213     }
214    
215     sub init {
216     my ($class, %kv) = @_;
217     my $file = exists $kv{file} ? $kv{file} : _file;
218    
219     my $secret = AnyEvent::MCP::random_bytes 64;
220    
221     File::Path::mkpath (File::Basename::dirname ($file), 0, 0700);
222    
223     sysopen my $fh, $file, O_WRONLY | O_CREAT | O_EXCL, 0600
224     or die "$file: $!\n";
225    
226     print {$fh} "[global]\nsecret = ", unpack ("H*", $secret), "\n"
227     or die "$file: $!\n";
228     close $fh or die "$file: $!\n";
229    
230     $class->new (file => $file)
231     }
232    
233     sub credential {
234     my ($self, $purpose, $name, $username) = @_;
235    
236     $username = "" unless defined $username;
237    
238     die "Unknown credential purpose '$purpose'\n"
239 root 1.5 unless $purpose =~ /\A(?:bearer|cookie)\z/;
240 root 1.1
241     my $secret = $self->{section}{global}{secret};
242     die "$self->{file}: missing or invalid secret\n"
243     unless $secret =~ /\A[0-9a-fA-F]{128}\z/;
244    
245     my $info = pack "N/a*N/a*N/a*N/a*",
246     "AnyEvent::MCP credential v1", $purpose,
247 root 1.2 AnyEvent::MCP::utf8enc ($name), AnyEvent::MCP::utf8enc ($username);
248 root 1.1
249     my $credential = MIME::Base64::encode_base64 (
250     AnyEvent::MCP::hkdf_sha512_expand (pack ("H*", $secret), $info, 32), ""
251     );
252     $credential =~ y!+/=!-_!d;
253    
254     $credential
255     }
256    
257 root 1.4 sub server_name {
258     my ($name) = @_;
259    
260     unless (defined $name) {
261     require File::Basename;
262     $name = File::Basename::basename ($0);
263     $name =~ s/\.(?:pl|perl)\z//;
264     $name = "perl" if $name eq "-e";
265     }
266    
267     $name =~ /\A[A-Za-z_][A-Za-z0-9_-]*\z/
268     or die "Invalid MCP server name '$name'\n";
269    
270     $name
271     }
272    
273     sub http_getopt {
274 root 1.5 my ($name, $bind, $print_url, $tokens) = @_;
275 root 1.4
276 root 1.5 "name=s" => $name, "bind=s" => $bind, "print-url" => $print_url, "token=s@" => $tokens
277 root 1.4 }
278    
279     sub server_port {
280     my ($self, $name) = @_;
281    
282     $name = server_name $name;
283    
284     my $info = pack "N/a*N/a*", "AnyEvent::MCP port v1", $name;
285     16384 + ((unpack "n", AnyEvent::MCP::hkdf_sha512_expand (
286     pack ("H*", $self->{section}{global}{secret}), $info, 2
287     )) & 16383)
288     }
289    
290     sub http_settings {
291     my ($self, $name, $bind) = @_;
292    
293     $name = server_name $name;
294     my $settings = $self->{section}{"server $name"} || {};
295     my $port = $self->server_port ($name);
296     my $configured = $settings->{bind} // "127.0.0.1:$port";
297     $configured = "127.0.0.1$configured" if $configured =~ /\A:[0-9]+\z/;
298     my ($configured_host, $configured_port) = AnyEvent::Socket::parse_hostport $configured, $port;
299     defined $configured_host && defined $configured_port
300     && $configured_port =~ /\A[0-9]+\z/ && $configured_port <= 65535
301     or die "Invalid MCP bind address '$configured'\n";
302    
303     $bind = $configured unless defined $bind;
304     $bind = "127.0.0.1$bind" if $bind =~ /\A:[0-9]+\z/;
305    
306     my ($host, $service) = AnyEvent::Socket::parse_hostport $bind, $configured_port;
307     defined $host && defined $service && $service =~ /\A[0-9]+\z/ && $service <= 65535
308     or die "Invalid MCP bind address '$bind'\n";
309    
310     my %kv = (
311     (map { exists $settings->{$_} ? ($_ => $settings->{$_}) : () }
312 root 1.5 qw(path url_host url_scheme)),
313 root 1.4 name => $name,
314     host => $host,
315     port => $service,
316     );
317    
318     unless (AnyEvent::Socket::parse_address $host) {
319     my ($error, @addresses) = Socket::getaddrinfo ($host, undef, { socktype => Socket::SOCK_STREAM () });
320     !$error && @addresses
321     or die "Cannot resolve MCP bind host '$host': $error\n";
322    
323     my ($error2, $address) = Socket::getnameinfo ($addresses[0]{addr}, Socket::NI_NUMERICHOST ());
324     !$error2 && length $address
325     or die "Cannot format MCP bind host '$host': $error2\n";
326    
327     $kv{url_host} //= $host;
328     $kv{host} = $address;
329     }
330    
331 root 1.5 if (exists $settings->{tokens}) {
332     $kv{tokens} = [split /\s*,\s*/, $settings->{tokens}, -1];
333     } else {
334 root 1.4 my $token = $self->credential ("cookie", $name);
335 root 1.5 $kv{tokens} = [$token];
336 root 1.4 }
337    
338     %kv
339     }
340    
341     sub http_url {
342     my ($self, $name, $bind, %options) = @_;
343    
344     # A printed URL needs a credential that survives the next process.
345     my $stored = ref ($self)->new (file => $self->{file});
346     $stored->{section}{global}{secret} eq $self->{section}{global}{secret}
347     or die "MCP master secret is not persistently stored in $self->{file}\n";
348    
349     require AnyEvent::MCP::Server::HTTP;
350     my $server = AnyEvent::MCP::Server::HTTP->new ($self->http_settings ($name, $bind), %options);
351     $server->url
352     }
353    
354 root 1.1 sub url {
355     my ($self, $name, $username) = @_;
356    
357     my $url = $self->{section}{"server $name"}{url}
358     or die "No URL configured for server '$name'\n";
359    
360 root 1.5 my $token = !defined $username && exists $self->{section}{"server $name"}{tokens}
361     ? (split /\s*,\s*/, $self->{section}{"server $name"}{tokens}, -1)[0]
362     : $self->credential ("cookie", $name, $username);
363     $url =~ s/\{(?:token|cookie)\}/$token/g;
364 root 1.1
365     $url
366     }
367    
368     1