ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/AnyEvent-MCP/lib/AnyEvent/MCP/Config.pm
Revision: 1.17
Committed: Mon Oct 19 16:42:29 2026 UTC (3 weeks, 5 days ago) by root
Branch: MAIN
Changes since 1.16: +29 -0 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 root 1.12 use AnyEvent::MCP::Config;
8    
9 root 1.1 my $config = AnyEvent::MCP::Config->global;
10 root 1.8 my $token = $config->credential ("token", "agni");
11 root 1.6 my $url = $config->http_url ("agni", undef, path => "/");
12 root 1.1
13     =head1 DESCRIPTION
14    
15 root 1.3 An MCP server and its clients often need matching credentials. Rather than
16     maintaining a separate password for each server and user, this module can
17 root 1.14 derive them from one private master secret. The invoked program basename,
18     instance name, optional username and credential purpose each contribute to
19     the result. Instances default to C<default>; different programs still derive
20     different credentials for that instance name.
21 root 1.3
22     The shared file format is described in L<AnyEvent::MCP/GLOBAL CONFIGURATION>.
23     Loading this module does not read any files. The first call to C<global>
24     reads the configuration, ensures a master secret exists and caches the object
25     for the process.
26 root 1.1
27     A missing secret is generated using C<AnyEvent::MCP::random_bytes> and saved
28     when possible. Saving failure produces a warning; the generated secret remains
29 root 1.3 usable in this process, but another process might derive different credentials.
30     A supplied invalid secret is an error. Changing or losing the master secret
31     invalidates all credentials derived from it.
32 root 1.1
33 root 1.13 Derivation uses C<derive_secret> with the random master key.
34 root 1.14 The application namespace, purpose, program, instance name and username are
35     separately length-prefixed, so distinct contexts derive different credentials.
36 root 1.3 Deriving a password does not create an account or configure the server to
37     accept it: authentication still belongs to the server.
38 root 1.1
39     =head1 METHODS
40    
41 root 1.12 =over
42    
43     =item $config = AnyEvent::MCP::Config->global (file => $file)
44 root 1.1
45     Returns the cached global configuration. The first call accepts the same
46     arguments as C<new>; later calls return the same object without rereading
47     the file. Call this explicitly during program startup.
48    
49 root 1.12 =item $config = AnyEvent::MCP::Config->new (file => $file)
50 root 1.1
51     Reads C<$file>, or the default file when omitted, and ensures a master
52     secret exists. Unlike C<global>, each call creates an independent object.
53     A missing file is allowed; other read errors are fatal. Sections are available
54     as C<< $config->{section}{$section} >>, each a hash reference of string
55 root 1.10 values. Named servers use C<[instance NAME]>.
56 root 1.1
57 root 1.12 =item $config = AnyEvent::MCP::Config->init (file => $file)
58 root 1.1
59     Explicitly creates the configuration file with a random secret, mode 0600.
60     Creates missing parent directories with mode 0700. Fails if the file already
61     exists, so it cannot accidentally replace an existing secret.
62    
63 root 1.13 =item $bytes = $config->derive_secret ($length, @info)
64    
65     =item $bytes = AnyEvent::MCP::Config->derive_secret ($length, @info)
66    
67     Derive C<$length> secret bytes from the configuration's master secret and
68     an ordered list of context byte strings C<@info>. The class form uses
69     C<global>, loading the default configuration on first use and reusing it
70     thereafter. The object form uses that object's configuration, including
71     one loaded from an explicit file.
72    
73     The same master secret and context always produce the same bytes. Use a
74     distinctive application/purpose string as the first context field, followed
75     by names or other values identifying the result:
76    
77     my $secret = AnyEvent::MCP::Config->derive_secret (
78     32, "my application token v1", AnyEvent::MCP::utf8enc ($name)
79     );
80    
81     The result contains raw bytes; encode it yourself if a printable token is
82     needed. This method does not add the result to a server's accepted tokens.
83    
84 root 1.14 The fixed C<anyevent-mcp> context field is prepended automatically.
85 root 1.13 Context fields are separately length-prefixed, so C<("ab", "c")> and
86     C<("a", "bc")> have different contexts. Encode character strings explicitly,
87     for example with C<AnyEvent::MCP::utf8enc>. Context arguments are left
88     unchanged.
89    
90     C<$length> must be an integer in 0..16320; zero returns an empty string.
91     Shorter results are prefixes of longer results for the same master secret
92     and context. Use distinct context fields for different purposes, rather
93     than relying on different lengths to separate them.
94    
95     Derivation uses HKDF-SHA512 expansion, with each context field prefixed by
96     its four-byte big-endian byte length. Changing or losing the master secret
97     changes every result derived from it.
98    
99 root 1.12 =item $credential = $config->credential ($purpose, $name, $username)
100 root 1.1
101     Derives a 32-byte credential, returned as unpadded URL-safe Base64.
102 root 1.14 C<$purpose> is C<token>. C<$name> is the instance name, and C<$username>
103 root 1.8 is optional. The default server credential is derived with no username.
104 root 1.1
105 root 1.14 Credentials use the context C<("anyevent-mcp", "token", PROGRAM, NAME, USER)>.
106     PROGRAM is the invoked basename, with a trailing C<.pl> or C<.perl> removed.
107     Changing the program or instance name changes the credential. This derivation
108     replaces the older name-only context; update stored client URLs.
109 root 1.12 A credential derived with C<$username> is B<not> automatically accepted
110     by the server. This method neither requires a server section nor configures
111     authentication.
112 root 1.1
113 root 1.14 =item $program = AnyEvent::MCP::Config::program_name ()
114    
115     Returns the invoked program basename, stripping C<.pl> or C<.perl>.
116     An inline Perl invocation uses C<perl>. This identity also selects the
117     C<[program PROGRAM]> configuration section; it is independent of routing aliases.
118    
119 root 1.12 =item $name = AnyEvent::MCP::Config::server_name ($name)
120 root 1.4
121 root 1.14 Validate a service-instance name, or use C<default> if undefined.
122 root 1.4 Names consist of an ASCII letter or underscore followed by letters, digits,
123     underscores or hyphens.
124    
125 root 1.12 =item @options = AnyEvent::MCP::Config::http_getopt (\$name, \$bind, \$print_url, \@tokens)
126 root 1.4
127     Return standard C<Getopt::Long> options referring to the caller's scalars.
128    
129 root 1.12 =item $port = $config->server_port ($name)
130 root 1.4
131     Derive a stable default port in 16384..32767 from the master secret and
132 root 1.14 program basename and service-instance name. Collisions produce ordinary listener bind errors.
133 root 1.4
134 root 1.12 =item %settings = $config->http_settings ($name, $bind)
135 root 1.4
136 root 1.10 Resolve HTTP settings from C<[instance NAME]> and an optional command-line
137 root 1.4 C<bind> override. The default is IPv4 loopback and the name-derived port.
138     The bind setting accepts C<ADDRESS:PORT>, C<[IPv6]:PORT>, C<ADDRESS>,
139     and C<:PORT> (which keeps the default loopback address).
140 root 1.12 Hostnames are resolved once when settings are obtained; their original
141     spelling is advertised unless C<url_host> overrides it. The returned options
142     include C<name>, C<host>, C<port> and C<tokens>, plus configured C<path>,
143     C<url_host> and C<url_scheme>. This does not start a listener.
144 root 1.4
145 root 1.17 =item @rules = $config->auto_rules ($name)
146    
147     Return ordered automatic permission rules from C<[program PROGRAM]> and
148     C<[instance NAME]>. C<auto-rule.SUFFIX = allow|deny TOOL[,ACTION[,CAPABILITY]]>
149     entries are sorted by key. The suffix is an arbitrary ordering label; instance
150     entries override matching program entries, and an empty value disables an
151     inherited entry. Command-line rules take precedence over these defaults.
152    
153 root 1.12 =item $url = $config->http_url ($name, $bind, %options)
154 root 1.4
155 root 1.8 Format a URL containing the first accepted token, without listening.
156     Port zero cannot be used because the kernel has not yet selected a port.
157     When the token is derived, the master secret must be persistently stored,
158     so the next process derives the same URL. Explicit static tokens and
159     C<auth =E<gt> "none"> do not require that persistence check.
160 root 1.4
161 root 1.12 =back
162    
163 root 1.1 =cut
164    
165     package AnyEvent::MCP::Config;
166    
167     use common::sense;
168     use AnyEvent::MCP ();
169 root 1.13 use Digest::SHA ();
170 root 1.1 use MIME::Base64 ();
171     use File::Basename ();
172     use File::Path ();
173     use Fcntl qw(O_WRONLY O_RDWR O_CREAT O_EXCL LOCK_EX);
174     use Errno qw(ENOENT);
175 root 1.4 use AnyEvent::Socket ();
176     use Socket ();
177 root 1.1
178     our @ISA = qw(AnyEvent::MCP::Base);
179    
180     sub _file {
181     my $dir = $ENV{XDG_CONFIG_HOME}
182     || ($ENV{HOME} || die "HOME is not set\n") . "/.config";
183    
184     "$dir/anyevent-mcp/global.ini"
185     }
186    
187     my $global;
188    
189     sub global {
190     my $class = shift;
191    
192     $global ||= $class->new (@_)
193     }
194    
195 root 1.16 sub new {
196 root 1.1 my ($class, %kv) = @_;
197 root 1.11
198 root 1.1 my $self = $class->SUPER::new (section => {}, %kv);
199     $self->{file} = _file unless exists $self->{file};
200    
201     if (open my $fh, "<:encoding(UTF-8)", $self->{file}) {
202     $self->_read ($fh);
203     close $fh or die "$self->{file}: $!\n";
204     } else {
205     die "$self->{file}: $!\n" unless $! == ENOENT;
206     }
207    
208     $self->_ensure_secret;
209    
210 root 1.11 $self->{section}{global}{secret} =~ /\A[0-9a-fA-F]{128}\z/
211     or die "$self->{file}: invalid secret; supply 128 hexadecimal digits\n";
212 root 1.1
213     $self
214     }
215    
216     sub _read {
217     my ($self, $fh) = @_;
218    
219     my @stat = stat $fh;
220     die "$self->{file}: configuration must be private and owned by this user\n"
221     if $stat[4] != $< || $stat[2] & 077;
222    
223 root 1.15 local $/;
224     AnyEvent::MCP::parse_ini scalar (<$fh>), sub {
225     my ($name, $kv) = @_;
226     my $section = $self->{section}{$name || "global"} ||= {};
227     %$section = (%$section, %$kv);
228     }, $self->{file};
229 root 1.1
230     }
231    
232     sub _ensure_secret {
233     my ($self) = @_;
234    
235     return if exists $self->{section}{global}{secret};
236    
237     my $secret = eval { unpack "H*", AnyEvent::MCP::random_bytes 64 };
238     die "$@Supply a securely generated 128-digit hexadecimal secret in $self->{file}\n"
239     if $@;
240    
241     eval {
242     File::Path::mkpath (File::Basename::dirname ($self->{file}), 0, 0700);
243     sysopen my $fh, $self->{file}, O_RDWR | O_CREAT, 0600
244     or die "$self->{file}: $!\n";
245     flock $fh, LOCK_EX or die "$self->{file}: $!\n";
246     binmode $fh, ":encoding(UTF-8)";
247    
248     my $disk = bless { file => $self->{file}, section => {} }, ref $self;
249     $disk->_read ($fh);
250    
251     unless (exists $disk->{section}{global}{secret}) {
252     seek $fh, 0, 2 or die "$self->{file}: $!\n";
253     print {$fh} "\n[global]\nsecret = $secret\n"
254     or die "$self->{file}: $!\n";
255     $disk->{section}{global}{secret} = $secret;
256     }
257    
258     close $fh or die "$self->{file}: $!\n";
259     $self->{section} = $disk->{section};
260     1
261     } or warn "Cannot save MCP master secret; credentials may only be stable for this process: $@";
262    
263     $self->{section}{global}{secret} = $secret
264     unless exists $self->{section}{global}{secret};
265     }
266    
267     sub init {
268     my ($class, %kv) = @_;
269 root 1.11
270 root 1.1 my $file = exists $kv{file} ? $kv{file} : _file;
271    
272     my $secret = AnyEvent::MCP::random_bytes 64;
273    
274     File::Path::mkpath (File::Basename::dirname ($file), 0, 0700);
275    
276     sysopen my $fh, $file, O_WRONLY | O_CREAT | O_EXCL, 0600
277     or die "$file: $!\n";
278    
279     print {$fh} "[global]\nsecret = ", unpack ("H*", $secret), "\n"
280     or die "$file: $!\n";
281     close $fh or die "$file: $!\n";
282    
283     $class->new (file => $file)
284     }
285    
286 root 1.13 sub _prk_derive_secret($$@) {
287     my ($prk, $length, @info) = @_;
288    
289     64 == length $prk
290     or die "derive_secret: key length must be 64 octets\n";
291     $length =~ /\A[0-9]+\z/ && $length <= 255 * 64
292     or die "derive_secret: length must be an integer in 0..16320\n";
293    
294 root 1.14 my $info = pack "(N/a*)*", "anyevent-mcp", @info;
295 root 1.13 my $t = "";
296     my $okm = "";
297     my $i = 0;
298    
299     while ($length > length $okm) {
300     $t = Digest::SHA::hmac_sha512 $t . $info . chr ++$i, $prk;
301     $okm .= $t;
302     }
303    
304     substr $okm, 0, $length
305     }
306    
307     sub derive_secret {
308     my ($self, $length, @info) = @_;
309    
310     $self = $self->global unless ref $self;
311    
312     my $secret = $self->{section}{global}{secret};
313     $secret =~ /\A[0-9a-fA-F]{128}\z/
314     or die "$self->{file}: missing or invalid secret\n";
315    
316     _prk_derive_secret (pack ("H*", $secret), $length, @info)
317     }
318    
319 root 1.1 sub credential {
320     my ($self, $purpose, $name, $username) = @_;
321    
322     $username = "" unless defined $username;
323    
324 root 1.11 $purpose eq "token"
325     or die "Unknown credential purpose '$purpose'\n";
326 root 1.1
327     my $credential = MIME::Base64::encode_base64 (
328 root 1.13 $self->derive_secret (
329 root 1.14 32, $purpose, AnyEvent::MCP::utf8enc (program_name ()),
330 root 1.13 AnyEvent::MCP::utf8enc ($name), AnyEvent::MCP::utf8enc ($username)
331     ), ""
332 root 1.1 );
333     $credential =~ y!+/=!-_!d;
334    
335     $credential
336     }
337    
338 root 1.14 sub program_name {
339     my $name = File::Basename::basename ($0);
340     $name =~ s/\.(?:pl|perl)\z//;
341     $name eq "-e" ? "perl" : $name
342     }
343    
344 root 1.4 sub server_name {
345     my ($name) = @_;
346    
347 root 1.14 $name = "default" unless defined $name;
348 root 1.4
349     $name =~ /\A[A-Za-z_][A-Za-z0-9_-]*\z/
350     or die "Invalid MCP server name '$name'\n";
351    
352     $name
353     }
354    
355     sub http_getopt {
356 root 1.5 my ($name, $bind, $print_url, $tokens) = @_;
357 root 1.4
358 root 1.5 "name=s" => $name, "bind=s" => $bind, "print-url" => $print_url, "token=s@" => $tokens
359 root 1.4 }
360    
361 root 1.17 sub auto_rules {
362     my ($self, $name) = @_;
363     my %settings = (
364     %{$self->{section}{"program " . program_name ()} || {}},
365     %{$self->{section}{"instance " . server_name ($name)} || {}},
366     );
367     my @rules;
368    
369     for my $key (sort grep /\Aauto-rule\./, keys %settings) {
370     $key =~ /\Aauto-rule\.(.+)\z/
371     or die "Invalid automatic permission rule key '$key'\n";
372     my $value = $settings{$key};
373     next unless length $value;
374     my ($decision, $spec) = $value =~ /\A(allow|deny)\s+(\S+)\z/
375     or die "Invalid configured automatic permission rule '$key'\n";
376     push @rules, [$decision, $spec];
377     }
378    
379     @rules
380     }
381    
382 root 1.4 sub server_port {
383     my ($self, $name) = @_;
384    
385     $name = server_name $name;
386    
387 root 1.14 16384 + ((unpack "n", $self->derive_secret (2, "port", AnyEvent::MCP::utf8enc (program_name ()), AnyEvent::MCP::utf8enc ($name))) & 16383)
388 root 1.4 }
389    
390     sub http_settings {
391     my ($self, $name, $bind) = @_;
392    
393     $name = server_name $name;
394 root 1.14 my $settings = {
395     %{ $self->{section}{"program " . program_name ()} },
396     %{ $self->{section}{"instance $name"} },
397     };
398 root 1.4 my $port = $self->server_port ($name);
399     my $configured = $settings->{bind} // "127.0.0.1:$port";
400     $configured = "127.0.0.1$configured" if $configured =~ /\A:[0-9]+\z/;
401     my ($configured_host, $configured_port) = AnyEvent::Socket::parse_hostport $configured, $port;
402     defined $configured_host && defined $configured_port
403     && $configured_port =~ /\A[0-9]+\z/ && $configured_port <= 65535
404     or die "Invalid MCP bind address '$configured'\n";
405    
406     $bind = $configured unless defined $bind;
407     $bind = "127.0.0.1$bind" if $bind =~ /\A:[0-9]+\z/;
408    
409     my ($host, $service) = AnyEvent::Socket::parse_hostport $bind, $configured_port;
410     defined $host && defined $service && $service =~ /\A[0-9]+\z/ && $service <= 65535
411     or die "Invalid MCP bind address '$bind'\n";
412    
413     my %kv = (
414     (map { exists $settings->{$_} ? ($_ => $settings->{$_}) : () }
415 root 1.5 qw(path url_host url_scheme)),
416 root 1.4 name => $name,
417     host => $host,
418     port => $service,
419     );
420    
421     unless (AnyEvent::Socket::parse_address $host) {
422     my ($error, @addresses) = Socket::getaddrinfo ($host, undef, { socktype => Socket::SOCK_STREAM () });
423     !$error && @addresses
424     or die "Cannot resolve MCP bind host '$host': $error\n";
425    
426 root 1.9 my (undef, $address) = AnyEvent::Socket::unpack_sockaddr $addresses[0]{addr};
427 root 1.4
428     $kv{url_host} //= $host;
429 root 1.9 $kv{host} = AnyEvent::Socket::format_address $address;
430 root 1.4 }
431    
432 root 1.11 $kv{tokens} = [exists $settings->{tokens}
433     ? split /\s*,\s*/, $settings->{tokens}, -1
434     : $self->credential ("token", $name)];
435 root 1.4
436     %kv
437     }
438    
439     sub http_url {
440     my ($self, $name, $bind, %options) = @_;
441    
442 root 1.14 $name = server_name $name;
443    
444 root 1.11 # only a derived token depends on the master secret surviving this process.
445 root 1.8 if (($options{auth} || "") ne "none"
446     && !exists $options{tokens}
447 root 1.14 && !exists $self->{section}{"instance $name"}{tokens}
448     && !exists $self->{section}{"program " . program_name ()}{tokens}) {
449 root 1.8 my $stored = ref ($self)->new (file => $self->{file});
450     $stored->{section}{global}{secret} eq $self->{section}{global}{secret}
451     or die "MCP master secret is not persistently stored in $self->{file}\n";
452     }
453 root 1.4
454     require AnyEvent::MCP::Server::HTTP;
455 root 1.8 my %http = $self->http_settings ($name, $bind);
456     delete $http{tokens} if ($options{auth} || "") eq "none";
457 root 1.11
458     AnyEvent::MCP::Server::HTTP->new (%http, %options)->url
459 root 1.4 }
460    
461 root 1.1
462     1