ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/AnyEvent-MCP/lib/AnyEvent/MCP/Config.pm
Revision: 1.29
Committed: Sat Nov 7 23:13:37 2026 UTC (7 days, 2 hours ago) by root
Branch: MAIN
Changes since 1.28: +1 -2 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.24 my $token = $config->user_credentials ("agni")->[0][2];
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.24 derive them from one private master secret. Users are defined by
18     C<user.NAME = GENERATION> in C<[global]>, C<[program PROGRAM]> or
19     C<[instance NAME]>, in that order of precedence. An absent global
20     C<user.default> is implicitly enabled at generation 1.
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.24 is optional. This is the older unmanaged derivation API; its result is not
104     automatically accepted by a server. Managed credentials are enumerated by
105     C<user_credentials>.
106 root 1.1
107 root 1.14 Credentials use the context C<("anyevent-mcp", "token", PROGRAM, NAME, USER)>.
108     PROGRAM is the invoked basename, with a trailing C<.pl> or C<.perl> removed.
109 root 1.18 Changing the program or instance name changes the credential.
110 root 1.12 A credential derived with C<$username> is B<not> automatically accepted
111     by the server. This method neither requires a server section nor configures
112     authentication.
113 root 1.1
114 root 1.24 =item $users = $config->users ($instance, $program)
115    
116     Return effective user definitions keyed by username, each with C<scope>
117     (global, program or instance) and signed C<generation>. Inheritance is
118     global, program, instance; a negative generation disables without fallback.
119    
120     =item $credentials = $config->user_credentials ($instance, $program)
121    
122     Return enabled users as C<[[USERNAME, PRINCIPAL, TOKEN]...]> with default
123     first. The principal is C<global:NAME>, C<program:NAME>, or C<instance:NAME>.
124     HKDF context includes program, instance, scope, user and generation. Rotating
125     a generation changes the token but preserves saved user permissions.
126    
127     =item $user = $config->connect_user ($instance, $program)
128    
129     Return the username allowed to register private Vim, browser WebSocket, or
130     multibroker reverse-MCP connections. C<connect_user> inherits through the
131     usual scopes and defaults to C<default>. Revoking that user disables these
132     connections until its generation is renewed or another user is selected.
133    
134 root 1.14 =item $program = AnyEvent::MCP::Config::program_name ()
135    
136     Returns the invoked program basename, stripping C<.pl> or C<.perl>.
137     An inline Perl invocation uses C<perl>. This identity also selects the
138     C<[program PROGRAM]> configuration section; it is independent of routing aliases.
139    
140 root 1.26 =item $user = AnyEvent::MCP::Config::user_name ($user)
141 root 1.4
142 root 1.26 Validate a managed username (ASCII letter or underscore, followed by
143     letters, digits, underscores or hyphens). Instance names are validated by
144     L<AnyEvent::MCP/SMALL UTILITIES>.
145 root 1.4
146 root 1.12 =item @options = AnyEvent::MCP::Config::http_getopt (\$name, \$bind, \$print_url, \@tokens)
147 root 1.4
148     Return standard C<Getopt::Long> options referring to the caller's scalars.
149    
150 root 1.12 =item $port = $config->server_port ($name)
151 root 1.4
152     Derive a stable default port in 16384..32767 from the master secret and
153 root 1.14 program basename and service-instance name. Collisions produce ordinary listener bind errors.
154 root 1.4
155 root 1.12 =item %settings = $config->http_settings ($name, $bind)
156 root 1.4
157 root 1.10 Resolve HTTP settings from C<[instance NAME]> and an optional command-line
158 root 1.4 C<bind> override. The default is IPv4 loopback and the name-derived port.
159     The bind setting accepts C<ADDRESS:PORT>, C<[IPv6]:PORT>, C<ADDRESS>,
160     and C<:PORT> (which keeps the default loopback address).
161 root 1.12 Hostnames are resolved once when settings are obtained; their original
162     spelling is advertised unless C<url_host> overrides it. The returned options
163     include C<name>, C<host>, C<port> and C<tokens>, plus configured C<path>,
164     C<url_host> and C<url_scheme>. This does not start a listener.
165 root 1.4
166 root 1.17 =item @rules = $config->auto_rules ($name)
167    
168     Return ordered automatic permission rules from C<[program PROGRAM]> and
169     C<[instance NAME]>. C<auto-rule.SUFFIX = allow|deny TOOL[,ACTION[,CAPABILITY]]>
170     entries are sorted by key. The suffix is an arbitrary ordering label; instance
171     entries override matching program entries, and an empty value disables an
172     inherited entry. Command-line rules take precedence over these defaults.
173    
174 root 1.12 =item $url = $config->http_url ($name, $bind, %options)
175 root 1.4
176 root 1.8 Format a URL containing the first accepted token, without listening.
177     Port zero cannot be used because the kernel has not yet selected a port.
178     When the token is derived, the master secret must be persistently stored,
179     so the next process derives the same URL. Explicit static tokens and
180     C<auth =E<gt> "none"> do not require that persistence check.
181 root 1.4
182 root 1.12 =back
183    
184 root 1.1 =cut
185    
186     package AnyEvent::MCP::Config;
187    
188     use common::sense;
189 root 1.25
190 root 1.13 use Digest::SHA ();
191 root 1.1 use MIME::Base64 ();
192     use File::Basename ();
193     use File::Path ();
194     use Fcntl qw(O_WRONLY O_RDWR O_CREAT O_EXCL LOCK_EX);
195     use Errno qw(ENOENT);
196 root 1.25
197 root 1.4 use AnyEvent::Socket ();
198 root 1.25 use AnyEvent::MCP ();
199 root 1.1
200     our @ISA = qw(AnyEvent::MCP::Base);
201    
202     sub _file {
203     my $dir = $ENV{XDG_CONFIG_HOME}
204     || ($ENV{HOME} || die "HOME is not set\n") . "/.config";
205    
206     "$dir/anyevent-mcp/global.ini"
207     }
208    
209     my $global;
210    
211     sub global {
212     my $class = shift;
213    
214     $global ||= $class->new (@_)
215     }
216    
217 root 1.16 sub new {
218 root 1.1 my ($class, %kv) = @_;
219 root 1.11
220 root 1.1 my $self = $class->SUPER::new (section => {}, %kv);
221     $self->{file} = _file unless exists $self->{file};
222    
223 root 1.27 if (open my $fh, "<:raw", $self->{file}) {
224 root 1.1 $self->_read ($fh);
225     close $fh or die "$self->{file}: $!\n";
226     } else {
227     die "$self->{file}: $!\n" unless $! == ENOENT;
228     }
229    
230     $self->_ensure_secret;
231    
232 root 1.11 $self->{section}{global}{secret} =~ /\A[0-9a-fA-F]{128}\z/
233     or die "$self->{file}: invalid secret; supply 128 hexadecimal digits\n";
234 root 1.1
235     $self
236     }
237    
238     sub _read {
239     my ($self, $fh) = @_;
240    
241 root 1.29 AnyEvent::MCP::parse_ini +(AnyEvent::MCP::file_load ($self->{file}, $fh)), sub {
242 root 1.15 my ($name, $kv) = @_;
243     my $section = $self->{section}{$name || "global"} ||= {};
244     %$section = (%$section, %$kv);
245     }, $self->{file};
246 root 1.1 }
247    
248     sub _ensure_secret {
249     my ($self) = @_;
250    
251     return if exists $self->{section}{global}{secret};
252    
253     my $secret = eval { unpack "H*", AnyEvent::MCP::random_bytes 64 };
254     die "$@Supply a securely generated 128-digit hexadecimal secret in $self->{file}\n"
255     if $@;
256    
257     eval {
258     File::Path::mkpath (File::Basename::dirname ($self->{file}), 0, 0700);
259     sysopen my $fh, $self->{file}, O_RDWR | O_CREAT, 0600
260     or die "$self->{file}: $!\n";
261     flock $fh, LOCK_EX or die "$self->{file}: $!\n";
262 root 1.28 binmode $fh, ":raw";
263 root 1.1
264     my $disk = bless { file => $self->{file}, section => {} }, ref $self;
265     $disk->_read ($fh);
266    
267     unless (exists $disk->{section}{global}{secret}) {
268     seek $fh, 0, 2 or die "$self->{file}: $!\n";
269     print {$fh} "\n[global]\nsecret = $secret\n"
270     or die "$self->{file}: $!\n";
271     $disk->{section}{global}{secret} = $secret;
272     }
273    
274     close $fh or die "$self->{file}: $!\n";
275     $self->{section} = $disk->{section};
276     1
277     } or warn "Cannot save MCP master secret; credentials may only be stable for this process: $@";
278    
279     $self->{section}{global}{secret} = $secret
280     unless exists $self->{section}{global}{secret};
281     }
282    
283     sub init {
284     my ($class, %kv) = @_;
285 root 1.11
286 root 1.1 my $file = exists $kv{file} ? $kv{file} : _file;
287    
288     my $secret = AnyEvent::MCP::random_bytes 64;
289    
290     File::Path::mkpath (File::Basename::dirname ($file), 0, 0700);
291    
292     sysopen my $fh, $file, O_WRONLY | O_CREAT | O_EXCL, 0600
293     or die "$file: $!\n";
294    
295     print {$fh} "[global]\nsecret = ", unpack ("H*", $secret), "\n"
296     or die "$file: $!\n";
297     close $fh or die "$file: $!\n";
298    
299     $class->new (file => $file)
300     }
301    
302 root 1.13 sub _prk_derive_secret($$@) {
303     my ($prk, $length, @info) = @_;
304    
305     64 == length $prk
306     or die "derive_secret: key length must be 64 octets\n";
307     $length =~ /\A[0-9]+\z/ && $length <= 255 * 64
308     or die "derive_secret: length must be an integer in 0..16320\n";
309    
310 root 1.14 my $info = pack "(N/a*)*", "anyevent-mcp", @info;
311 root 1.13 my $t = "";
312     my $okm = "";
313     my $i = 0;
314    
315     while ($length > length $okm) {
316     $t = Digest::SHA::hmac_sha512 $t . $info . chr ++$i, $prk;
317     $okm .= $t;
318     }
319    
320     substr $okm, 0, $length
321     }
322    
323     sub derive_secret {
324     my ($self, $length, @info) = @_;
325    
326     $self = $self->global unless ref $self;
327    
328     my $secret = $self->{section}{global}{secret};
329     $secret =~ /\A[0-9a-fA-F]{128}\z/
330     or die "$self->{file}: missing or invalid secret\n";
331    
332     _prk_derive_secret (pack ("H*", $secret), $length, @info)
333     }
334    
335 root 1.21 sub program_name {
336     my $name = File::Basename::basename ($0);
337     $name =~ s/\.(?:pl|perl)\z//;
338     $name eq "-e" ? "perl" : $name
339     }
340    
341 root 1.26 sub user_name {
342 root 1.21 my ($name) = @_;
343    
344 root 1.26 defined $name && $name =~ /\A[A-Za-z_][A-Za-z0-9_-]*\z/
345     or die "Invalid MCP user name\n";
346 root 1.21
347     $name
348     }
349    
350 root 1.1 sub credential {
351     my ($self, $purpose, $name, $username) = @_;
352    
353     $username = "" unless defined $username;
354    
355 root 1.11 $purpose eq "token"
356     or die "Unknown credential purpose '$purpose'\n";
357 root 1.1
358     my $credential = MIME::Base64::encode_base64 (
359 root 1.13 $self->derive_secret (
360 root 1.21 32,
361     $purpose,
362     (AnyEvent::MCP::utf8enc program_name),
363     (AnyEvent::MCP::utf8enc $name),
364     (AnyEvent::MCP::utf8enc $username)
365 root 1.13 ), ""
366 root 1.1 );
367     $credential =~ y!+/=!-_!d;
368    
369     $credential
370     }
371    
372 root 1.24 sub users {
373     my ($self, $name, $program) = @_;
374    
375 root 1.26 $name = AnyEvent::MCP::instance_name $name;
376 root 1.24 $program //= program_name;
377     my %users = (default => { scope => "global", generation => 1 });
378    
379     for my $entry (
380     ["global", $self->{section}{global}],
381     ["program", $self->{section}{"program $program"}],
382     ["instance", $self->{section}{"instance $name"}],
383     ) {
384     my ($scope, $section) = @$entry;
385     next unless $section;
386    
387     for my $key (grep /\Auser\./, keys %$section) {
388     my ($user) = $key =~ /\Auser\.([A-Za-z_][A-Za-z0-9_-]*)\z/
389     or die "Invalid MCP user '$key'\n";
390     my $generation = $section->{$key};
391     $generation =~ /\A-?[1-9][0-9]*\z/ && length $generation < 18
392     or die "Invalid MCP user generation for '$key'\n";
393     $users{$user} = { scope => $scope, generation => $generation };
394     }
395     }
396    
397     \%users
398     }
399    
400     sub user_credentials {
401     my ($self, $name, $program) = @_;
402    
403 root 1.26 $name = AnyEvent::MCP::instance_name $name;
404 root 1.24 $program //= program_name;
405     my $users = $self->users ($name, $program);
406     my @credentials;
407    
408     for my $user (sort { ($a ne "default") <=> ($b ne "default") || $a cmp $b } keys %$users) {
409     my $info = $users->{$user};
410     next unless $info->{generation} > 0;
411     my $token = MIME::Base64::encode_base64 ($self->derive_secret (
412     32, "user-token-v1", AnyEvent::MCP::utf8enc ($program),
413     AnyEvent::MCP::utf8enc ($name), $info->{scope},
414     AnyEvent::MCP::utf8enc ($user), "$info->{generation}"
415     ), "");
416     $token =~ y!+/=!-_!d;
417     push @credentials, [$user, "$info->{scope}:$user", $token];
418     }
419    
420     \@credentials
421     }
422    
423     sub connect_user {
424     my ($self, $name, $program) = @_;
425    
426 root 1.26 $name = AnyEvent::MCP::instance_name $name;
427 root 1.24 $program //= program_name;
428     my $user = $self->{section}{"instance $name"}{connect_user}
429     // $self->{section}{"program $program"}{connect_user}
430     // $self->{section}{global}{connect_user} // "default";
431 root 1.26 user_name $user
432 root 1.24 }
433    
434 root 1.4 sub http_getopt {
435 root 1.5 my ($name, $bind, $print_url, $tokens) = @_;
436 root 1.4
437 root 1.24 "name=s" => $name, "bind=s" => $bind, "print-url|print-urls:s" => $print_url, "token=s@" => $tokens
438 root 1.4 }
439    
440 root 1.17 sub auto_rules {
441     my ($self, $name) = @_;
442     my %settings = (
443     %{$self->{section}{"program " . program_name ()} || {}},
444 root 1.26 %{$self->{section}{"instance " . AnyEvent::MCP::instance_name ($name)} || {}},
445 root 1.17 );
446     my @rules;
447    
448     for my $key (sort grep /\Aauto-rule\./, keys %settings) {
449     $key =~ /\Aauto-rule\.(.+)\z/
450     or die "Invalid automatic permission rule key '$key'\n";
451     my $value = $settings{$key};
452     next unless length $value;
453     my ($decision, $spec) = $value =~ /\A(allow|deny)\s+(\S+)\z/
454     or die "Invalid configured automatic permission rule '$key'\n";
455     push @rules, [$decision, $spec];
456     }
457    
458     @rules
459     }
460    
461 root 1.4 sub server_port {
462     my ($self, $name) = @_;
463    
464 root 1.26 $name = AnyEvent::MCP::instance_name $name;
465 root 1.4
466 root 1.21 16384 + (
467     (unpack "n", $self->derive_secret (
468     2,
469     "port",
470     (AnyEvent::MCP::utf8enc program_name),
471     (AnyEvent::MCP::utf8enc $name))
472     ) & 16383)
473 root 1.4 }
474    
475     sub http_settings {
476     my ($self, $name, $bind) = @_;
477    
478 root 1.26 $name = AnyEvent::MCP::instance_name $name;
479 root 1.14 my $settings = {
480     %{ $self->{section}{"program " . program_name ()} },
481     %{ $self->{section}{"instance $name"} },
482     };
483 root 1.4 my $port = $self->server_port ($name);
484     my $configured = $settings->{bind} // "127.0.0.1:$port";
485     $configured = "127.0.0.1$configured" if $configured =~ /\A:[0-9]+\z/;
486     my ($configured_host, $configured_port) = AnyEvent::Socket::parse_hostport $configured, $port;
487     defined $configured_host && defined $configured_port
488     && $configured_port =~ /\A[0-9]+\z/ && $configured_port <= 65535
489     or die "Invalid MCP bind address '$configured'\n";
490    
491     $bind = $configured unless defined $bind;
492     $bind = "127.0.0.1$bind" if $bind =~ /\A:[0-9]+\z/;
493    
494     my ($host, $service) = AnyEvent::Socket::parse_hostport $bind, $configured_port;
495     defined $host && defined $service && $service =~ /\A[0-9]+\z/ && $service <= 65535
496     or die "Invalid MCP bind address '$bind'\n";
497    
498     my %kv = (
499     (map { exists $settings->{$_} ? ($_ => $settings->{$_}) : () }
500 root 1.23 qw(path url_host url_scheme tls_cert tls_key)),
501 root 1.4 name => $name,
502     host => $host,
503     port => $service,
504     );
505    
506     unless (AnyEvent::Socket::parse_address $host) {
507 root 1.20 my $cv = AnyEvent->condvar;
508     AnyEvent::Socket::resolve_sockaddr $host, $service, "tcp", 0, 0, $cv;
509     my ($resolved) = $cv->recv;
510     $resolved or die "Cannot resolve MCP bind host '$host'\n";
511 root 1.4
512 root 1.20 my (undef, $address) = AnyEvent::Socket::unpack_sockaddr $resolved->[3];
513 root 1.4 $kv{url_host} //= $host;
514 root 1.9 $kv{host} = AnyEvent::Socket::format_address $address;
515 root 1.4 }
516    
517 root 1.24 if (exists $settings->{tokens}) {
518     $kv{tokens} = [split /\s*,\s*/, $settings->{tokens}, -1];
519     $kv{connect_tokens} = $kv{tokens};
520     } else {
521     my $credentials = $self->user_credentials ($name);
522     $kv{tokens} = [map $_->[2], @$credentials];
523     $kv{token_users} = { map { $_->[2] => $_->[1] } @$credentials };
524     my $connect = $self->connect_user ($name);
525     $kv{connect_tokens} = [map $_->[2], grep $_->[0] eq $connect, @$credentials];
526     }
527 root 1.4
528     %kv
529     }
530    
531     sub http_url {
532     my ($self, $name, $bind, %options) = @_;
533    
534 root 1.26 $name = AnyEvent::MCP::instance_name $name;
535 root 1.14
536 root 1.11 # only a derived token depends on the master secret surviving this process.
537 root 1.8 if (($options{auth} || "") ne "none"
538     && !exists $options{tokens}
539 root 1.14 && !exists $self->{section}{"instance $name"}{tokens}
540 root 1.24 && !exists $self->{section}{"program " . program_name ()}{tokens}
541     && !exists $self->{section}{global}{tokens}) {
542 root 1.22 my $stored = (ref $self)->new (file => $self->{file});
543 root 1.8 $stored->{section}{global}{secret} eq $self->{section}{global}{secret}
544     or die "MCP master secret is not persistently stored in $self->{file}\n";
545     }
546 root 1.4
547     require AnyEvent::MCP::Server::HTTP;
548 root 1.8 my %http = $self->http_settings ($name, $bind);
549     delete $http{tokens} if ($options{auth} || "") eq "none";
550 root 1.11
551     AnyEvent::MCP::Server::HTTP->new (%http, %options)->url
552 root 1.4 }
553    
554 root 1.1
555     1