ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/AnyEvent-MCP/lib/AnyEvent/MCP/Config.pm
Revision: 1.33
Committed: Tue Nov 10 21:05:58 2026 UTC (21 hours, 23 minutes ago) by root
Branch: MAIN
CVS Tags: HEAD
Changes since 1.32: +7 -5 lines
Log Message:
*** empty log message ***

File Contents

# Content
1 =head1 NAME
2
3 AnyEvent::MCP::Config - named servers and derived MCP credentials
4
5 =head1 SYNOPSIS
6
7 use AnyEvent::MCP::Config;
8
9 my $config = AnyEvent::MCP::Config::global_config;
10 my $token = $config->user_credentials ("agni")->[0][2];
11 my $url = $config->http_url ("agni", undef, path => "/");
12
13 =head1 DESCRIPTION
14
15 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 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
22 The shared file format is described in L<AnyEvent::MCP/GLOBAL CONFIGURATION>.
23 Loading this module does not read any files. C<global_config> loads the
24 configuration, ensures a persistent master secret exists and caches the
25 initialized object for the process.
26
27 A missing secret is generated using C<AnyEvent::MCP::random_bytes> and
28 appended under a file lock. Failure to save the secret is fatal, rather
29 than using a temporary secret that other processes cannot reproduce.
30 A supplied invalid secret is an error. Changing or losing the master
31 secret invalidates all credentials derived from it.
32
33 Derivation uses C<derive_secret> with the random master key.
34 The application namespace, purpose, program, instance name and username are
35 separately length-prefixed, so distinct contexts derive different credentials.
36 Deriving a password does not create an account or configure the server to
37 accept it: authentication still belongs to the server.
38
39 =head1 METHODS
40
41 =over
42
43 =item $config = AnyEvent::MCP::Config::global_config (file => $file)
44
45 Return the process-global configuration object, loaded and initialized.
46 The first call accepts the same arguments as C<new>, and ensures that the
47 master secret is persistently stored. Later calls return the same object
48 without rereading or reinitializing it.
49
50 =item $config = AnyEvent::MCP::Config->new (file => $file)
51
52 Construct an independent configuration object without reading or writing
53 the file. C<$file> defaults to the global configuration path.
54
55 =item $config->load
56
57 Reload the file, replacing the object's sections. A missing file yields an
58 empty configuration; other read and parsing errors are fatal. Sections are
59 available as C<< $config->{section}{$section} >>, each a hash reference of
60 string values. Named servers use C<[instance NAME]>. An optional raw file
61 handle can be supplied when the caller already holds the file lock.
62
63 =item $config->save
64
65 Write the loaded sections back as canonical UTF-8 INI text, sorting
66 sections and keys. Comments and original formatting are not retained.
67 The caller must hold C<AnyEvent::MCP::file_lock> through the update.
68 Returns the configuration object.
69
70 =item $config->ensure_secret
71
72 Ensure that C<[global]> contains a valid persistent master secret. If
73 missing, lock and reread the file, generate a secret only if still absent,
74 and append it without changing other settings. Fail if it cannot be saved
75 or is invalid. Returns the configuration object.
76
77 =item $bytes = $config->derive_secret ($length, @info)
78
79 =item $bytes = AnyEvent::MCP::Config->derive_secret ($length, @info)
80
81 Derive C<$length> secret bytes from the configuration's master secret and
82 an ordered list of context byte strings C<@info>. The class form uses
83 C<global_config>, loading the default configuration on first use and reusing it
84 thereafter. The object form uses that object's configuration, including
85 one loaded from an explicit file.
86
87 The same master secret and context always produce the same bytes. Use a
88 distinctive application/purpose string as the first context field, followed
89 by names or other values identifying the result:
90
91 my $secret = AnyEvent::MCP::Config->derive_secret (
92 32, "my application token v1", AnyEvent::MCP::utf8enc ($name)
93 );
94
95 The result contains raw bytes; encode it yourself if a printable token is
96 needed. This method does not add the result to a server's accepted tokens.
97
98 The fixed C<anyevent-mcp> context field is prepended automatically.
99 Context fields are separately length-prefixed, so C<("ab", "c")> and
100 C<("a", "bc")> have different contexts. Encode character strings explicitly,
101 for example with C<AnyEvent::MCP::utf8enc>. Context arguments are left
102 unchanged.
103
104 C<$length> must be an integer in 0..16320; zero returns an empty string.
105 Shorter results are prefixes of longer results for the same master secret
106 and context. Use distinct context fields for different purposes, rather
107 than relying on different lengths to separate them.
108
109 Derivation uses HKDF-SHA512 expansion, with each context field prefixed by
110 its four-byte big-endian byte length. Changing or losing the master secret
111 changes every result derived from it.
112
113 =item $credential = $config->credential ($purpose, $name, $username)
114
115 Derives a 32-byte credential, returned as unpadded URL-safe Base64.
116 C<$purpose> is C<token>. C<$name> is the instance name, and C<$username>
117 is optional. This is the older unmanaged derivation API; its result is not
118 automatically accepted by a server. Managed credentials are enumerated by
119 C<user_credentials>.
120
121 Credentials use the context C<("anyevent-mcp", "token", PROGRAM, NAME, USER)>.
122 PROGRAM is the invoked basename, with a trailing C<.pl> or C<.perl> removed.
123 Changing the program or instance name changes the credential.
124 A credential derived with C<$username> is B<not> automatically accepted
125 by the server. This method neither requires a server section nor configures
126 authentication.
127
128 =item $users = $config->users ($instance, $program)
129
130 Return effective user definitions keyed by username, each with C<scope>
131 (global, program or instance) and signed C<generation>. Inheritance is
132 global, program, instance; a negative generation disables without fallback.
133
134 =item $credentials = $config->user_credentials ($instance, $program)
135
136 Return enabled users as C<[[USERNAME, PRINCIPAL, TOKEN]...]> with default
137 first. The principal is C<global:NAME>, C<program:NAME>, or C<instance:NAME>.
138 HKDF context includes program, instance, scope, user and generation. Rotating
139 a generation changes the token but preserves saved user permissions.
140
141 =item $user = $config->connect_user ($instance, $program)
142
143 Return the username allowed to register private Vim, browser WebSocket, or
144 multibroker reverse-MCP connections. C<connect_user> inherits through the
145 usual scopes and defaults to C<default>. Revoking that user disables these
146 connections until its generation is renewed or another user is selected.
147
148 =item $program = AnyEvent::MCP::Config::program_name ()
149
150 Returns the invoked program basename, stripping C<.pl> or C<.perl>.
151 An inline Perl invocation uses C<perl>. This identity also selects the
152 C<[program PROGRAM]> configuration section; it is independent of routing aliases.
153
154 =item $user = AnyEvent::MCP::Config::user_name ($user)
155
156 Validate a managed username (ASCII letter or underscore, followed by
157 letters, digits, underscores or hyphens). Instance names are validated by
158 L<AnyEvent::MCP/SMALL UTILITIES>.
159
160 =item @options = AnyEvent::MCP::Config::http_getopt (\$name, \$bind, \$print_url, \@tokens)
161
162 Return standard C<Getopt::Long> options referring to the caller's scalars.
163
164 =item $port = $config->server_port ($name)
165
166 Derive a stable default port in 16384..32767 from the master secret and
167 program basename and service-instance name. Collisions produce ordinary listener bind errors.
168
169 =item %settings = $config->http_settings ($name, $bind, $default_port)
170
171 Resolve HTTP settings from C<[instance NAME]> and an optional command-line
172 C<bind> override. The default is IPv4 loopback and the name-derived port;
173 an optional C<default_port> replaces the derived port when no bind port
174 is specified.
175 The bind setting accepts C<ADDRESS:PORT>, C<[IPv6]:PORT>, C<ADDRESS>,
176 and C<:PORT> (which keeps the default loopback address).
177 Hostnames are resolved once when settings are obtained; their original
178 spelling is advertised unless C<url_host> overrides it. The returned options
179 include C<name>, C<host>, C<port> and C<tokens>, plus configured C<path>,
180 C<url_host> and C<url_scheme>. This does not start a listener.
181
182 =item @rules = $config->auto_rules ($name)
183
184 Return ordered automatic permission rules from C<[program PROGRAM]> and
185 C<[instance NAME]>. C<auto-rule.SUFFIX = allow|deny TOOL[,ACTION[,CAPABILITY]]>
186 entries are sorted by key. The suffix is an arbitrary ordering label; instance
187 entries override matching program entries, and an empty value disables an
188 inherited entry. Command-line rules take precedence over these defaults.
189
190 =item $url = $config->http_url ($name, $bind, %options)
191
192 Format a URL containing the first accepted token, without listening.
193 Port zero cannot be used because the kernel has not yet selected a port.
194 When the token is derived, the master secret must be persistently stored,
195 so the next process derives the same URL. Explicit static tokens and
196 C<auth =E<gt> "none"> do not require that persistence check.
197
198 =back
199
200 =head1 INI FUNCTIONS
201
202 These functions work with Unicode character strings, not octets. Use
203 L<AnyEvent::MCP/file_load> and L<AnyEvent::MCP/file_save> for UTF-8
204 file I/O. Binary values belong in Base64 or hexadecimal form.
205
206 =over
207
208 =item $sections = AnyEvent::MCP::Config::parse_ini ($text, $filename)
209
210 Parse Unicode INI text into C<[[NAME, {KEY =E<gt> VALUE}], ...]>.
211 Sections retain file order, including repeated section names and the
212 initial unnamed section C<"">. Duplicate keys within one section use
213 the final value. C<$filename> is optional, for line-numbered errors.
214
215 =item $merged = AnyEvent::MCP::Config::merge_sections ($sections, $default)
216
217 Return a hash mapping section names to key/value hashes, with later
218 sections overriding earlier ones. When C<$default> is given, unnamed
219 sections use that name (C<global> for global configuration and
220 C<account> for IMAP accounts). Use the original ordered list instead
221 for files where repeated sections are meaningful, such as permission rules.
222
223 =item $text = AnyEvent::MCP::Config::serialize_ini ($sections)
224
225 Serialize an ordered section list to canonical INI text. Section order
226 is preserved; keys within each section are sorted. Values must be
227 single-line character strings. Comments and whitespace formatting
228 are deliberately not preserved on rewrite.
229
230 =back
231
232 =cut
233
234 package AnyEvent::MCP::Config;
235
236 use common::sense;
237
238 use Digest::SHA ();
239 use MIME::Base64 ();
240 use File::Basename ();
241 use Errno qw(ENOENT);
242
243 use AnyEvent::Socket ();
244 use AnyEvent::MCP ();
245
246 our @ISA = qw(AnyEvent::MCP::Base);
247
248 sub parse_ini {
249 my ($text, $file) = @_;
250 $file //= "INI";
251 my @sections = (["", {}]);
252 my $line = 0;
253
254 for (split /\n/, $text) {
255 ++$line;
256 s/\r\z//;
257 next if /\A\s*(?:[#;]|\z)/;
258 if (/\A\s*\[([^\]\r\n]+)\]\s*\z/) {
259 push @sections, [$1, {}];
260 } elsif (/\A\s*([^=\s]+)\s*=\s*(.*?)\s*\z/) {
261 $sections[-1][1]{$1} = $2;
262 } else {
263 die "$file:$line: invalid INI line\n";
264 }
265 }
266
267 \@sections
268 }
269
270 sub merge_sections {
271 my ($sections, $default) = @_;
272 my %merged;
273
274 for (@$sections) {
275 my ($name, $kv) = @$_;
276 $name = $default if defined $default && $name eq "";
277 my $values = $merged{$name} ||= {};
278 %$values = (%$values, %$kv);
279 }
280
281 \%merged
282 }
283
284 sub serialize_ini {
285 my ($sections) = @_;
286
287 join "", map {
288 my ($name, $values) = @$_;
289 $name !~ /[\]\r\n]/ or die "Invalid INI section name\n";
290 (length $name ? "[$name]\n" : "")
291 . (join "", map {
292 length && !/[=\s]/ && !/\A[#;]/
293 or die "Invalid INI key\n";
294 my $value = $values->{$_};
295 defined $value && !ref $value && $value !~ /[\r\n]/
296 or die "Invalid INI value for $_\n";
297 "$_ = $value\n"
298 } sort keys %$values)
299 . "\n"
300 } @$sections
301 }
302
303 sub _file {
304 my $dir = $ENV{XDG_CONFIG_HOME}
305 || ($ENV{HOME} || die "HOME is not set\n") . "/.config";
306
307 "$dir/anyevent-mcp/global.ini"
308 }
309
310 my $global;
311
312 sub global_config {
313 $global //= __PACKAGE__->new (@_)->load->ensure_secret
314 }
315
316 sub new {
317 my ($class, %kv) = @_;
318
319 my $self = $class->SUPER::new (section => {}, %kv);
320 $self->{file} = _file unless exists $self->{file};
321 $self
322 }
323
324 sub load {
325 my ($self, $fh) = @_;
326 $self->{section} = {};
327
328 unless ($fh) {
329 open $fh, "<:raw", $self->{file} or do {
330 die "$self->{file}: $!\n" unless $! == ENOENT;
331 return $self
332 };
333 }
334
335 seek $fh, 0, 0 or die "$self->{file}: $!\n";
336 $self->{section} = merge_sections (parse_ini (AnyEvent::MCP::file_load ($self->{file}, $fh), $self->{file}), "global");
337 close $fh or die "$self->{file}: $!\n" unless @_ > 1;
338
339 $self
340 }
341
342 sub save {
343 my ($self) = @_;
344
345 AnyEvent::MCP::file_save $self->{file},
346 serialize_ini [map [$_, $self->{section}{$_}], sort keys %{$self->{section}}];
347 $self
348 }
349
350 sub ensure_secret {
351 my ($self) = @_;
352
353 unless (exists $self->{section}{global}{secret}) {
354 my $lock = AnyEvent::MCP::file_lock $self->{file};
355 $self->load ($lock);
356
357 unless (exists $self->{section}{global}{secret}) {
358 my $secret = eval { unpack "H*", AnyEvent::MCP::random_bytes 64 };
359 die "$@Supply a securely generated 128-digit hexadecimal secret in $self->{file}\n"
360 if $@;
361 seek $lock, 0, 2 or die "$self->{file}: $!\n";
362 print {$lock} "\n[global]\nsecret = $secret\n" or die "$self->{file}: $!\n";
363 $self->{section}{global}{secret} = $secret;
364 }
365
366 close $lock or die "$self->{file}: $!\n";
367 }
368
369 $self->{section}{global}{secret} =~ /\A[0-9a-fA-F]{128}\z/
370 or die "$self->{file}: invalid secret; supply 128 hexadecimal digits\n";
371 $self
372 }
373
374 sub _prk_derive_secret($$@) {
375 my ($prk, $length, @info) = @_;
376
377 64 == length $prk
378 or die "derive_secret: key length must be 64 octets\n";
379 $length =~ /\A[0-9]+\z/ && $length <= 255 * 64
380 or die "derive_secret: length must be an integer in 0..16320\n";
381
382 my $info = pack "(N/a*)*", "anyevent-mcp", @info;
383 my $t = "";
384 my $okm = "";
385 my $i = 0;
386
387 while ($length > length $okm) {
388 $t = Digest::SHA::hmac_sha512 $t . $info . chr ++$i, $prk;
389 $okm .= $t;
390 }
391
392 substr $okm, 0, $length
393 }
394
395 sub derive_secret {
396 my ($self, $length, @info) = @_;
397
398 $self = global_config () unless ref $self;
399
400 my $secret = $self->{section}{global}{secret};
401 $secret =~ /\A[0-9a-fA-F]{128}\z/
402 or die "$self->{file}: missing or invalid secret\n";
403
404 _prk_derive_secret (pack ("H*", $secret), $length, @info)
405 }
406
407 sub program_name {
408 my $name = File::Basename::basename ($0);
409 $name =~ s/\.(?:pl|perl)\z//;
410 $name eq "-e" ? "perl" : $name
411 }
412
413 sub user_name {
414 my ($name) = @_;
415
416 defined $name && $name =~ /\A[A-Za-z_][A-Za-z0-9_-]*\z/
417 or die "Invalid MCP user name\n";
418
419 $name
420 }
421
422 sub credential {
423 my ($self, $purpose, $name, $username) = @_;
424
425 $username = "" unless defined $username;
426
427 $purpose eq "token"
428 or die "Unknown credential purpose '$purpose'\n";
429
430 my $credential = MIME::Base64::encode_base64 (
431 $self->derive_secret (
432 32,
433 $purpose,
434 (AnyEvent::MCP::utf8enc program_name),
435 (AnyEvent::MCP::utf8enc $name),
436 (AnyEvent::MCP::utf8enc $username)
437 ), ""
438 );
439 $credential =~ y!+/=!-_!d;
440
441 $credential
442 }
443
444 sub users {
445 my ($self, $name, $program) = @_;
446
447 $name = AnyEvent::MCP::instance_name $name;
448 $program //= program_name;
449 my %users = (default => { scope => "global", generation => 1 });
450
451 for my $entry (
452 ["global", $self->{section}{global}],
453 ["program", $self->{section}{"program $program"}],
454 ["instance", $self->{section}{"instance $name"}],
455 ) {
456 my ($scope, $section) = @$entry;
457 next unless $section;
458
459 for my $key (grep /\Auser\./, keys %$section) {
460 my ($user) = $key =~ /\Auser\.([A-Za-z_][A-Za-z0-9_-]*)\z/
461 or die "Invalid MCP user '$key'\n";
462 my $generation = $section->{$key};
463 $generation =~ /\A-?[1-9][0-9]*\z/ && length $generation < 18
464 or die "Invalid MCP user generation for '$key'\n";
465 $users{$user} = { scope => $scope, generation => $generation };
466 }
467 }
468
469 \%users
470 }
471
472 sub user_credentials {
473 my ($self, $name, $program) = @_;
474
475 $name = AnyEvent::MCP::instance_name $name;
476 $program //= program_name;
477 my $users = $self->users ($name, $program);
478 my @credentials;
479
480 for my $user (sort { ($a ne "default") <=> ($b ne "default") || $a cmp $b } keys %$users) {
481 my $info = $users->{$user};
482 next unless $info->{generation} > 0;
483 my $token = MIME::Base64::encode_base64 ($self->derive_secret (
484 32, "user-token-v1", AnyEvent::MCP::utf8enc ($program),
485 AnyEvent::MCP::utf8enc ($name), $info->{scope},
486 AnyEvent::MCP::utf8enc ($user), "$info->{generation}"
487 ), "");
488 $token =~ y!+/=!-_!d;
489 push @credentials, [$user, "$info->{scope}:$user", $token];
490 }
491
492 \@credentials
493 }
494
495 sub connect_user {
496 my ($self, $name, $program) = @_;
497
498 $name = AnyEvent::MCP::instance_name $name;
499 $program //= program_name;
500 my $user = $self->{section}{"instance $name"}{connect_user}
501 // $self->{section}{"program $program"}{connect_user}
502 // $self->{section}{global}{connect_user} // "default";
503 user_name $user
504 }
505
506 sub http_getopt {
507 my ($name, $bind, $print_url, $tokens) = @_;
508
509 "name=s" => $name, "bind=s" => $bind, "print-url|print-urls:s" => $print_url, "token=s@" => $tokens
510 }
511
512 sub auto_rules {
513 my ($self, $name) = @_;
514 my %settings = (
515 %{$self->{section}{"program " . program_name ()} || {}},
516 %{$self->{section}{"instance " . AnyEvent::MCP::instance_name ($name)} || {}},
517 );
518 my @rules;
519
520 for my $key (sort grep /\Aauto-rule\./, keys %settings) {
521 $key =~ /\Aauto-rule\.(.+)\z/
522 or die "Invalid automatic permission rule key '$key'\n";
523 my $value = $settings{$key};
524 next unless length $value;
525 my ($decision, $spec) = $value =~ /\A(allow|deny)\s+(\S+)\z/
526 or die "Invalid configured automatic permission rule '$key'\n";
527 push @rules, [$decision, $spec];
528 }
529
530 @rules
531 }
532
533 sub server_port {
534 my ($self, $name) = @_;
535
536 $name = AnyEvent::MCP::instance_name $name;
537
538 16384 + (
539 (unpack "n", $self->derive_secret (
540 2,
541 "port",
542 (AnyEvent::MCP::utf8enc program_name),
543 (AnyEvent::MCP::utf8enc $name))
544 ) & 16383)
545 }
546
547 sub http_settings {
548 my ($self, $name, $bind, $default_port) = @_;
549
550 $name = AnyEvent::MCP::instance_name $name;
551 my $settings = {
552 %{ $self->{section}{"program " . program_name ()} },
553 %{ $self->{section}{"instance $name"} },
554 };
555 my $port = $default_port // $self->server_port ($name);
556 my $configured = $settings->{bind} // "127.0.0.1:$port";
557 $configured = "127.0.0.1$configured" if $configured =~ /\A:[0-9]+\z/;
558 my ($configured_host, $configured_port) = AnyEvent::Socket::parse_hostport $configured, $port;
559 defined $configured_host && defined $configured_port
560 && $configured_port =~ /\A[0-9]+\z/ && $configured_port <= 65535
561 or die "Invalid MCP bind address '$configured'\n";
562
563 $bind = $configured unless defined $bind;
564 $bind = "127.0.0.1$bind" if $bind =~ /\A:[0-9]+\z/;
565
566 my ($host, $service) = AnyEvent::Socket::parse_hostport $bind, $configured_port;
567 defined $host && defined $service && $service =~ /\A[0-9]+\z/ && $service <= 65535
568 or die "Invalid MCP bind address '$bind'\n";
569
570 my %kv = (
571 (map { exists $settings->{$_} ? ($_ => $settings->{$_}) : () }
572 qw(path url_host url_scheme tls_cert tls_key)),
573 name => $name,
574 host => $host,
575 port => $service,
576 );
577
578 unless (AnyEvent::Socket::parse_address $host) {
579 my $cv = AnyEvent->condvar;
580 AnyEvent::Socket::resolve_sockaddr $host, $service, "tcp", 0, 0, $cv;
581 my ($resolved) = $cv->recv;
582 $resolved or die "Cannot resolve MCP bind host '$host'\n";
583
584 my (undef, $address) = AnyEvent::Socket::unpack_sockaddr $resolved->[3];
585 $kv{url_host} //= $host;
586 $kv{host} = AnyEvent::Socket::format_address $address;
587 }
588
589 if (exists $settings->{tokens}) {
590 $kv{tokens} = [split /\s*,\s*/, $settings->{tokens}, -1];
591 $kv{connect_tokens} = $kv{tokens};
592 } else {
593 my $credentials = $self->user_credentials ($name);
594 $kv{tokens} = [map $_->[2], @$credentials];
595 $kv{token_users} = { map { $_->[2] => $_->[1] } @$credentials };
596 my $connect = $self->connect_user ($name);
597 $kv{connect_tokens} = [map $_->[2], grep $_->[0] eq $connect, @$credentials];
598 }
599
600 %kv
601 }
602
603 sub http_url {
604 my ($self, $name, $bind, %options) = @_;
605
606 $name = AnyEvent::MCP::instance_name $name;
607
608 # only a derived token depends on the master secret surviving this process.
609 if (($options{auth} || "") ne "none"
610 && !exists $options{tokens}
611 && !exists $self->{section}{"instance $name"}{tokens}
612 && !exists $self->{section}{"program " . program_name ()}{tokens}
613 && !exists $self->{section}{global}{tokens}) {
614 my $stored = (ref $self)->new (file => $self->{file})->load;
615 $stored->{section}{global}{secret} eq $self->{section}{global}{secret}
616 or die "MCP master secret is not persistently stored in $self->{file}\n";
617 }
618
619 require AnyEvent::MCP::Server::HTTP;
620 my %http = $self->http_settings ($name, $bind, delete $options{default_port});
621 delete $http{tokens} if ($options{auth} || "") eq "none";
622
623 AnyEvent::MCP::Server::HTTP->new (%http, %options)->url
624 }
625
626
627 1