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