@@ -55,15 +55,15 @@ Reguli de împăcare: |
||
| 55 | 55 |
|
| 56 | 56 |
## Listener mDNS |
| 57 | 57 |
|
| 58 |
-`scripts/mdns_host_seed.pl` ascultă multicast mDNS pe `224.0.0.251:5353`, colectează recorduri `A` pentru nume `*.local` din intervale private/link-local și seeduiește baza de observații mDNS. |
|
| 58 |
+`scripts/mdns_host_seed.pl` ascultă multicast mDNS pe `224.0.0.251:5353`, colectează recorduri `A` pentru nume `*.local` din intervale private/link-local și seeduiește baza runtime SQLite. |
|
| 59 | 59 |
|
| 60 | 60 |
Locația implicită: |
| 61 | 61 |
|
| 62 | 62 |
```text |
| 63 |
-var/mdns-observations.yaml |
|
| 63 |
+var/host-manager.sqlite#mdns_observations |
|
| 64 | 64 |
``` |
| 65 | 65 |
|
| 66 |
-Regulă importantă: listenerul mDNS nu modifică registry-ul SQLite, `config/hosts.yaml` sau `config/local-hosts.tsv`. Seed-ul mDNS rămâne observație separată până la review. |
|
| 66 |
+Regulă importantă: listenerul mDNS scrie doar în tabelul `mdns_observations`; nu modifică registry-ul de hosturi, `config/hosts.yaml` sau `config/local-hosts.tsv`. Seed-ul mDNS rămâne observație separată până la review. |
|
| 67 | 67 |
|
| 68 | 68 |
Rulare manuală pentru test: |
| 69 | 69 |
|
@@ -34,7 +34,6 @@ sudo dnf install nginx |
||
| 34 | 34 |
config/hosts.yaml |
| 35 | 35 |
config/local-hosts.tsv |
| 36 | 36 |
var/host-manager.sqlite |
| 37 |
- var/mdns-observations.yaml |
|
| 38 | 37 |
scripts/host_manager.pl |
| 39 | 38 |
scripts/mdns_host_seed.pl |
| 40 | 39 |
scripts/sync_local_hosts.sh |
@@ -102,4 +101,4 @@ Nu se adaugă wildcard local. Doar acest nume exact trebuie publicat. |
||
| 102 | 101 |
|
| 103 | 102 |
## mDNS discovery |
| 104 | 103 |
|
| 105 |
-`host-manager-mdns` este un listener separat care observă mDNS și seeduiește `var/mdns-observations.yaml`. Listenerul nu modifică registry-ul SQLite, `config/hosts.yaml` sau `config/local-hosts.tsv`. |
|
| 104 |
+`host-manager-mdns` este un listener separat care observă mDNS și scrie direct în tabelul SQLite `mdns_observations`. Listenerul nu modifică host registry-ul, `config/hosts.yaml` sau `config/local-hosts.tsv`. |
|
@@ -1,7 +1,7 @@ |
||
| 1 | 1 |
[Unit] |
| 2 | 2 |
Description=Xdev Host Manager mDNS Seed Listener |
| 3 |
-After=network-online.target |
|
| 4 |
-Wants=network-online.target |
|
| 3 |
+After=network-online.target host-manager.service |
|
| 4 |
+Wants=network-online.target host-manager.service |
|
| 5 | 5 |
|
| 6 | 6 |
[Service] |
| 7 | 7 |
Type=simple |
@@ -10,7 +10,7 @@ Group=host-manager |
||
| 10 | 10 |
WorkingDirectory=/usr/local/xdev-host-manager |
| 11 | 11 |
EnvironmentFile=/etc/xdev/host-manager.env |
| 12 | 12 |
ExecStartPre=+/usr/bin/install -d -o host-manager -g host-manager /usr/local/xdev-host-manager/var |
| 13 |
-ExecStart=/usr/bin/perl /usr/local/xdev-host-manager/scripts/mdns_host_seed.pl --database /usr/local/xdev-host-manager/var/mdns-observations.yaml |
|
| 13 |
+ExecStart=/usr/bin/perl /usr/local/xdev-host-manager/scripts/mdns_host_seed.pl --db /usr/local/xdev-host-manager/var/host-manager.sqlite |
|
| 14 | 14 |
Restart=on-failure |
| 15 | 15 |
RestartSec=3 |
| 16 | 16 |
NoNewPrivileges=true |
@@ -7,19 +7,20 @@ use strict; |
||
| 7 | 7 |
use warnings; |
| 8 | 8 |
|
| 9 | 9 |
use Cwd qw(abs_path); |
| 10 |
-use Fcntl qw(:flock); |
|
| 11 | 10 |
use File::Basename qw(dirname); |
| 12 | 11 |
use File::Path qw(make_path); |
| 13 | 12 |
use Getopt::Long qw(GetOptions); |
| 14 | 13 |
use IO::Socket::INET; |
| 15 | 14 |
use POSIX qw(strftime); |
| 16 | 15 |
use Socket qw(IPPROTO_IP IP_ADD_MEMBERSHIP SO_REUSEADDR inet_aton inet_ntoa sockaddr_in); |
| 16 |
+use DBI; |
|
| 17 | 17 |
|
| 18 | 18 |
my $script_dir = dirname(abs_path($0)); |
| 19 | 19 |
my $project_dir = dirname($script_dir); |
| 20 | 20 |
|
| 21 | 21 |
my %opt = ( |
| 22 |
- database => $ENV{HOST_MANAGER_MDNS_DB} || "$project_dir/var/mdns-observations.yaml",
|
|
| 22 |
+ db => $ENV{HOST_MANAGER_DB} || "$project_dir/var/host-manager.sqlite",
|
|
| 23 |
+ worker_id => $ENV{HOST_MANAGER_MDNS_WORKER_ID} || 'mdns-listener',
|
|
| 23 | 24 |
bind => $ENV{HOST_MANAGER_MDNS_BIND} || '0.0.0.0',
|
| 24 | 25 |
group => $ENV{HOST_MANAGER_MDNS_GROUP} || '224.0.0.251',
|
| 25 | 26 |
port => $ENV{HOST_MANAGER_MDNS_PORT} || 5353,
|
@@ -30,7 +31,8 @@ my %opt = ( |
||
| 30 | 31 |
); |
| 31 | 32 |
|
| 32 | 33 |
GetOptions( |
| 33 |
- 'database|db=s' => \$opt{database},
|
|
| 34 |
+ 'db=s' => \$opt{db},
|
|
| 35 |
+ 'worker-id=s' => \$opt{worker_id},
|
|
| 34 | 36 |
'bind=s' => \$opt{bind},
|
| 35 | 37 |
'group=s' => \$opt{group},
|
| 36 | 38 |
'port=i' => \$opt{port},
|
@@ -56,7 +58,9 @@ setsockopt($socket, IPPROTO_IP, IP_ADD_MEMBERSHIP, inet_aton($opt{group}) . inet
|
||
| 56 | 58 |
or die "Cannot join mDNS multicast group $opt{group}: $!\n";
|
| 57 | 59 |
|
| 58 | 60 |
print "mdns host seed listening on udp://$opt{bind}:$opt{port} group $opt{group}\n" if $opt{verbose};
|
| 59 |
-print "mDNS source database: $opt{database}\n" if $opt{verbose};
|
|
| 61 |
+my $dbh = open_database(\%opt); |
|
| 62 |
+print "mDNS SQLite database: $opt{db}\n" if $opt{verbose};
|
|
| 63 |
+print "mDNS worker id: $opt{worker_id}\n" if $opt{verbose};
|
|
| 60 | 64 |
|
| 61 | 65 |
my $deadline = $opt{timeout} ? time() + $opt{timeout} : 0;
|
| 62 | 66 |
while (1) {
|
@@ -79,7 +83,7 @@ while (1) {
|
||
| 79 | 83 |
my @records = grep { record_is_usable($_) } parse_mdns_packet($packet);
|
| 80 | 84 |
next unless @records; |
| 81 | 85 |
|
| 82 |
- my $changed = seed_observations(\%opt, \@records, $peer_ip); |
|
| 86 |
+ my $changed = seed_observations($dbh, \%opt, \@records, $peer_ip); |
|
| 83 | 87 |
print "stored " . scalar(@$changed) . " observation change(s)\n" if $opt{verbose} && @$changed;
|
| 84 | 88 |
last if $opt{once} && @$changed;
|
| 85 | 89 |
} |
@@ -91,7 +95,8 @@ sub usage {
|
||
| 91 | 95 |
Usage: perl scripts/mdns_host_seed.pl [options] |
| 92 | 96 |
|
| 93 | 97 |
Options: |
| 94 |
- --database path mDNS source database. Defaults to var/mdns-observations.yaml. |
|
| 98 |
+ --db path SQLite database. Defaults to var/host-manager.sqlite. |
|
| 99 |
+ --worker-id id data_workers.worker_id. Defaults to mdns-listener. |
|
| 95 | 100 |
--bind addr Local bind address. Defaults to 0.0.0.0. |
| 96 | 101 |
--group addr Multicast group. Defaults to 224.0.0.251. |
| 97 | 102 |
--port n UDP port. Defaults to 5353. |
@@ -100,8 +105,8 @@ Options: |
||
| 100 | 105 |
--dry-run Print proposed observation changes without writing. |
| 101 | 106 |
--verbose Print listener and change details. |
| 102 | 107 |
|
| 103 |
-Only A records for private/link-local .local names are collected. hosts.yaml is |
|
| 104 |
-not modified here; it remains a generated output fed by source databases. |
|
| 108 |
+Only A records for private/link-local .local names are collected. Observations |
|
| 109 |
+are upserted into SQLite table mdns_observations. hosts.yaml is not modified. |
|
| 105 | 110 |
EOF |
| 106 | 111 |
} |
| 107 | 112 |
|
@@ -182,127 +187,127 @@ sub ip_is_observable {
|
||
| 182 | 187 |
|| $ip =~ /\A169\.254\.\d+\.\d+\z/; |
| 183 | 188 |
} |
| 184 | 189 |
|
| 190 |
+sub open_database {
|
|
| 191 |
+ my ($opt) = @_; |
|
| 192 |
+ ensure_parent_dir($opt->{db});
|
|
| 193 |
+ my $dbh = DBI->connect( |
|
| 194 |
+ "dbi:SQLite:dbname=$opt->{db}",
|
|
| 195 |
+ '', |
|
| 196 |
+ '', |
|
| 197 |
+ {
|
|
| 198 |
+ RaiseError => 1, |
|
| 199 |
+ PrintError => 0, |
|
| 200 |
+ AutoCommit => 1, |
|
| 201 |
+ sqlite_unicode => 1, |
|
| 202 |
+ }, |
|
| 203 |
+ ) or die "Cannot open SQLite database $opt->{db}\n";
|
|
| 204 |
+ $dbh->do('PRAGMA journal_mode = WAL');
|
|
| 205 |
+ $dbh->do('PRAGMA foreign_keys = ON');
|
|
| 206 |
+ ensure_runtime_schema_exists($dbh); |
|
| 207 |
+ upsert_worker($dbh, $opt->{worker_id});
|
|
| 208 |
+ return $dbh; |
|
| 209 |
+} |
|
| 210 |
+ |
|
| 211 |
+sub ensure_runtime_schema_exists {
|
|
| 212 |
+ my ($dbh) = @_; |
|
| 213 |
+ for my $table (qw(data_workers mdns_observations)) {
|
|
| 214 |
+ my ($exists) = $dbh->selectrow_array( |
|
| 215 |
+ "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?", |
|
| 216 |
+ undef, |
|
| 217 |
+ $table, |
|
| 218 |
+ ); |
|
| 219 |
+ die "Missing SQLite table $table; start host-manager once to initialize schema\n" unless $exists; |
|
| 220 |
+ } |
|
| 221 |
+} |
|
| 222 |
+ |
|
| 223 |
+sub upsert_worker {
|
|
| 224 |
+ my ($dbh, $worker_id) = @_; |
|
| 225 |
+ my $now = iso_now(); |
|
| 226 |
+ $dbh->do( |
|
| 227 |
+ 'INSERT INTO data_workers (worker_id, worker_type, name, status, source, last_run_at, notes, created_at, updated_at) ' |
|
| 228 |
+ . "VALUES (?, 'mdns', 'mDNS listener', 'active', 'udp://224.0.0.251:5353', ?, 'mDNS observation collector source.', ?, ?) " |
|
| 229 |
+ . 'ON CONFLICT(worker_id) DO UPDATE SET worker_type = excluded.worker_type, name = excluded.name, status = excluded.status, ' |
|
| 230 |
+ . 'source = excluded.source, last_run_at = excluded.last_run_at, notes = excluded.notes, updated_at = excluded.updated_at', |
|
| 231 |
+ undef, |
|
| 232 |
+ $worker_id, |
|
| 233 |
+ $now, |
|
| 234 |
+ $now, |
|
| 235 |
+ $now, |
|
| 236 |
+ ); |
|
| 237 |
+} |
|
| 238 |
+ |
|
| 185 | 239 |
sub seed_observations {
|
| 186 |
- my ($opt, $records, $peer_ip) = @_; |
|
| 187 |
- my $database = $opt->{database};
|
|
| 188 |
- ensure_parent_dir($database); |
|
| 189 |
- open my $lock_fh, '+>>', $database or die "Cannot open $database: $!\n"; |
|
| 190 |
- flock($lock_fh, LOCK_EX) or die "Cannot lock $database: $!\n"; |
|
| 191 |
- seek($lock_fh, 0, 0); |
|
| 192 |
- local $/; |
|
| 193 |
- my $text = <$lock_fh>; |
|
| 194 |
- my $db = parse_observations_yaml($text || ''); |
|
| 240 |
+ my ($dbh, $opt, $records, $peer_ip) = @_; |
|
| 195 | 241 |
my @changes; |
| 196 | 242 |
|
| 197 | 243 |
for my $record (@$records) {
|
| 198 |
- my $change = merge_observation($db, $record, $peer_ip); |
|
| 244 |
+ my $change = merge_observation($dbh, $opt, $record, $peer_ip); |
|
| 199 | 245 |
push @changes, $change if $change; |
| 200 | 246 |
} |
| 201 | 247 |
|
| 202 |
- if (@changes) {
|
|
| 203 |
- if ($opt->{dry_run}) {
|
|
| 204 |
- print change_line($_) . "\n" for @changes; |
|
| 205 |
- } else {
|
|
| 206 |
- $db->{updated_at} = iso_now();
|
|
| 207 |
- seek($lock_fh, 0, 0); |
|
| 208 |
- truncate($lock_fh, 0) or die "Cannot truncate $database: $!\n"; |
|
| 209 |
- print {$lock_fh} render_observations_yaml($db);
|
|
| 210 |
- close $lock_fh or die "Cannot close $database: $!\n"; |
|
| 211 |
- } |
|
| 248 |
+ if (@changes && $opt->{dry_run}) {
|
|
| 249 |
+ print change_line($_) . "\n" for @changes; |
|
| 212 | 250 |
} |
| 213 | 251 |
|
| 214 | 252 |
return \@changes; |
| 215 | 253 |
} |
| 216 | 254 |
|
| 217 | 255 |
sub merge_observation {
|
| 218 |
- my ($db, $record, $peer_ip) = @_; |
|
| 256 |
+ my ($dbh, $opt, $record, $peer_ip) = @_; |
|
| 219 | 257 |
my $now = iso_now(); |
| 220 |
- my $id = id_from_mdns_name($record->{name});
|
|
| 221 | 258 |
my $key = "$record->{name}|$record->{ip}";
|
| 222 |
- my $existing; |
|
| 223 |
- for my $observation (@{ $db->{observations} || [] }) {
|
|
| 224 |
- if (($observation->{key} || '') eq $key) {
|
|
| 225 |
- $existing = $observation; |
|
| 226 |
- last; |
|
| 227 |
- } |
|
| 228 |
- } |
|
| 259 |
+ my $existing = $dbh->selectrow_hashref( |
|
| 260 |
+ 'SELECT observation_key, seen_count FROM mdns_observations WHERE observation_key = ?', |
|
| 261 |
+ undef, |
|
| 262 |
+ $key, |
|
| 263 |
+ ); |
|
| 264 |
+ my $raw = raw_observation($record, $peer_ip); |
|
| 229 | 265 |
|
| 230 | 266 |
if (!$existing) {
|
| 231 |
- push @{ $db->{observations} }, {
|
|
| 232 |
- key => $key, |
|
| 233 |
- id => $id, |
|
| 234 |
- name => $record->{name},
|
|
| 235 |
- ip => $record->{ip},
|
|
| 236 |
- first_seen => $now, |
|
| 237 |
- last_seen => $now, |
|
| 238 |
- seen_count => 1, |
|
| 239 |
- last_peer => $peer_ip, |
|
| 240 |
- ttl => int($record->{ttl} || 0),
|
|
| 241 |
- }; |
|
| 242 |
- return { action => 'created', id => $id, name => $record->{name}, ip => $record->{ip} };
|
|
| 243 |
- } |
|
| 244 |
- |
|
| 245 |
- $existing->{last_seen} = $now;
|
|
| 246 |
- $existing->{seen_count} = int($existing->{seen_count} || 0) + 1;
|
|
| 247 |
- $existing->{last_peer} = $peer_ip;
|
|
| 248 |
- $existing->{ttl} = int($record->{ttl} || 0);
|
|
| 249 |
- return { action => 'updated', id => $existing->{id}, name => $record->{name}, ip => $record->{ip} };
|
|
| 250 |
-} |
|
| 251 |
- |
|
| 252 |
-sub parse_observations_yaml {
|
|
| 253 |
- my ($text) = @_; |
|
| 254 |
- my %db = ( |
|
| 255 |
- version => 1, |
|
| 256 |
- updated_at => '', |
|
| 257 |
- source => 'mdns', |
|
| 258 |
- observations => [], |
|
| 259 |
- ); |
|
| 260 |
- my ($section, $current); |
|
| 261 |
- for my $line (split /\n/, $text) {
|
|
| 262 |
- next if $line =~ /^\s*$/ || $line =~ /^\s*#/; |
|
| 263 |
- if ($line =~ /^version:\s*(\d+)/) {
|
|
| 264 |
- $db{version} = int($1);
|
|
| 265 |
- } elsif ($line =~ /^updated_at:\s*(.+)$/) {
|
|
| 266 |
- $db{updated_at} = yaml_unquote($1);
|
|
| 267 |
- } elsif ($line =~ /^source:\s*(.+)$/) {
|
|
| 268 |
- $db{source} = yaml_unquote($1);
|
|
| 269 |
- } elsif ($line =~ /^observations:\s*$/) {
|
|
| 270 |
- $section = 'observations'; |
|
| 271 |
- } elsif (($section || '') eq 'observations' && $line =~ /^ - key:\s*(.+)$/) {
|
|
| 272 |
- $current = { key => yaml_unquote($1) };
|
|
| 273 |
- push @{ $db{observations} }, $current;
|
|
| 274 |
- } elsif ($current && $line =~ /^ ([A-Za-z0-9_]+):\s*(.*)$/) {
|
|
| 275 |
- my ($key, $value) = ($1, yaml_unquote($2)); |
|
| 276 |
- $value = int($value || 0) if $key =~ /\A(?:seen_count|ttl)\z/; |
|
| 277 |
- $current->{$key} = $value;
|
|
| 267 |
+ unless ($opt->{dry_run}) {
|
|
| 268 |
+ $dbh->do( |
|
| 269 |
+ 'INSERT INTO mdns_observations ' |
|
| 270 |
+ . '(observation_key, worker_id, host_fqdn, observed_name, ip_address, rr_type, ttl, first_seen, last_seen, seen_count, last_peer, raw) ' |
|
| 271 |
+ . "VALUES (?, ?, NULL, ?, ?, 'A', ?, ?, ?, 1, ?, ?)", |
|
| 272 |
+ undef, |
|
| 273 |
+ $key, |
|
| 274 |
+ $opt->{worker_id},
|
|
| 275 |
+ $record->{name},
|
|
| 276 |
+ $record->{ip},
|
|
| 277 |
+ int($record->{ttl} || 0),
|
|
| 278 |
+ $now, |
|
| 279 |
+ $now, |
|
| 280 |
+ $peer_ip, |
|
| 281 |
+ $raw, |
|
| 282 |
+ ); |
|
| 278 | 283 |
} |
| 284 |
+ return { action => 'created', key => $key, name => $record->{name}, ip => $record->{ip} };
|
|
| 279 | 285 |
} |
| 280 |
- return \%db; |
|
| 281 |
-} |
|
| 282 | 286 |
|
| 283 |
-sub render_observations_yaml {
|
|
| 284 |
- my ($db) = @_; |
|
| 285 |
- my $out = "version: " . int($db->{version} || 1) . "\n";
|
|
| 286 |
- $out .= "updated_at: " . yq($db->{updated_at} || iso_now()) . "\n";
|
|
| 287 |
- $out .= "source: " . yq($db->{source} || 'mdns') . "\n";
|
|
| 288 |
- $out .= "observations:\n"; |
|
| 289 |
- for my $observation (sort { ($a->{name} || '') cmp ($b->{name} || '') || ($a->{ip} || '') cmp ($b->{ip} || '') } @{ $db->{observations} || [] }) {
|
|
| 290 |
- $out .= " - key: " . yq($observation->{key}) . "\n";
|
|
| 291 |
- for my $key (qw(id name ip first_seen last_seen seen_count last_peer ttl)) {
|
|
| 292 |
- $out .= " $key: " . yq($observation->{$key} || '') . "\n";
|
|
| 293 |
- } |
|
| 287 |
+ unless ($opt->{dry_run}) {
|
|
| 288 |
+ $dbh->do( |
|
| 289 |
+ 'UPDATE mdns_observations SET ttl = ?, last_seen = ?, seen_count = seen_count + 1, last_peer = ?, raw = ? WHERE observation_key = ?', |
|
| 290 |
+ undef, |
|
| 291 |
+ int($record->{ttl} || 0),
|
|
| 292 |
+ $now, |
|
| 293 |
+ $peer_ip, |
|
| 294 |
+ $raw, |
|
| 295 |
+ $key, |
|
| 296 |
+ ); |
|
| 294 | 297 |
} |
| 295 |
- return $out; |
|
| 298 |
+ return { action => 'updated', key => $key, name => $record->{name}, ip => $record->{ip} };
|
|
| 296 | 299 |
} |
| 297 | 300 |
|
| 298 |
-sub id_from_mdns_name {
|
|
| 299 |
- my ($name) = @_; |
|
| 300 |
- $name =~ s/\.local\z//; |
|
| 301 |
- $name =~ s/\([0-9]+\)\z//; |
|
| 302 |
- $name = lc($name); |
|
| 303 |
- $name =~ s/[^a-z0-9_.-]+/-/g; |
|
| 304 |
- $name =~ s/^-+|-+$//g; |
|
| 305 |
- return $name; |
|
| 301 |
+sub raw_observation {
|
|
| 302 |
+ my ($record, $peer_ip) = @_; |
|
| 303 |
+ return join(' ', (
|
|
| 304 |
+ 'source=mdns', |
|
| 305 |
+ 'rr_type=A', |
|
| 306 |
+ 'name=' . ($record->{name} || ''),
|
|
| 307 |
+ 'ip=' . ($record->{ip} || ''),
|
|
| 308 |
+ 'ttl=' . int($record->{ttl} || 0),
|
|
| 309 |
+ 'peer=' . ($peer_ip || ''), |
|
| 310 |
+ )); |
|
| 306 | 311 |
} |
| 307 | 312 |
|
| 308 | 313 |
sub ensure_parent_dir {
|
@@ -316,26 +321,6 @@ sub change_line {
|
||
| 316 | 321 |
return join(' ', map { "$_=$change->{$_}" } sort keys %$change);
|
| 317 | 322 |
} |
| 318 | 323 |
|
| 319 |
-sub yq {
|
|
| 320 |
- my ($value) = @_; |
|
| 321 |
- $value = '' unless defined $value; |
|
| 322 |
- $value =~ s/\\/\\\\/g; |
|
| 323 |
- $value =~ s/"/\\"/g; |
|
| 324 |
- return qq("$value");
|
|
| 325 |
-} |
|
| 326 |
- |
|
| 327 |
-sub yaml_unquote {
|
|
| 328 |
- my ($value) = @_; |
|
| 329 |
- $value = '' unless defined $value; |
|
| 330 |
- $value =~ s/^\s+|\s+$//g; |
|
| 331 |
- if ($value =~ /^"(.*)"$/) {
|
|
| 332 |
- $value = $1; |
|
| 333 |
- $value =~ s/\\"/"/g; |
|
| 334 |
- $value =~ s/\\\\/\\/g; |
|
| 335 |
- } |
|
| 336 |
- return $value; |
|
| 337 |
-} |
|
| 338 |
- |
|
| 339 | 324 |
sub iso_now {
|
| 340 | 325 |
return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
|
| 341 | 326 |
} |