feat: update ot synchronous gpio and rotate keys
This commit is contained in:
parent
16ad3c6181
commit
7d538f1942
10 changed files with 61 additions and 48 deletions
22
Cargo.lock
generated
22
Cargo.lock
generated
|
|
@ -399,6 +399,15 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gpiod"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f851b1c607b36b4a493448ef80d8693bf74145712074c667a008a58264f8da49"
|
||||||
|
dependencies = [
|
||||||
|
"gpiod-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gpiod-core"
|
name = "gpiod-core"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
|
|
@ -783,6 +792,7 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
"gpiod",
|
||||||
"libc",
|
"libc",
|
||||||
"noisebell-common",
|
"noisebell-common",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|
@ -790,7 +800,6 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-gpiod",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
@ -1560,17 +1569,6 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio-gpiod"
|
|
||||||
version = "0.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ce15fa0021a7acacd2be506f72aeb5044a0a8b53d684963f133b37ace5c57f47"
|
|
||||||
dependencies = [
|
|
||||||
"gpiod-core",
|
|
||||||
"libc",
|
|
||||||
"tokio",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-macros"
|
name = "tokio-macros"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
|
|
|
||||||
10
pi/README.md
10
pi/README.md
|
|
@ -57,7 +57,7 @@ This setup expects SSH key login for user `pi`; it does not configure a password
|
||||||
After boot, verify SSH works:
|
After boot, verify SSH works:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
ssh pi@noisebridge-pi.local
|
ssh pi@noisebell-pi.local
|
||||||
```
|
```
|
||||||
|
|
||||||
## Add the Pi host key to age recipients
|
## Add the Pi host key to age recipients
|
||||||
|
|
@ -67,7 +67,7 @@ The deploy flow decrypts secrets locally on your laptop, but the Pi host key sho
|
||||||
Grab the Pi host key:
|
Grab the Pi host key:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
ssh-keyscan noisebridge-pi.local 2>/dev/null | grep ed25519
|
ssh-keyscan noisebell-pi.local 2>/dev/null | grep ed25519
|
||||||
```
|
```
|
||||||
|
|
||||||
Add that key to `secrets/secrets.nix` for:
|
Add that key to `secrets/secrets.nix` for:
|
||||||
|
|
@ -99,7 +99,7 @@ These stay encrypted in git. The deploy script decrypts them locally on your lap
|
||||||
From your laptop:
|
From your laptop:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
scripts/deploy-pios-pi.sh pi@noisebridge-pi.local
|
scripts/deploy-pios-pi.sh pi@noisebell-pi.local
|
||||||
```
|
```
|
||||||
|
|
||||||
If you only know the IP:
|
If you only know the IP:
|
||||||
|
|
@ -141,7 +141,7 @@ The deploy script:
|
||||||
|
|
||||||
- installs the Tailscale package if missing
|
- installs the Tailscale package if missing
|
||||||
- enables `tailscaled`
|
- enables `tailscaled`
|
||||||
- runs `tailscale up --auth-key=... --hostname=noisebridge-pi`
|
- runs `tailscale up --auth-key=... --hostname=noisebell-pi`
|
||||||
|
|
||||||
So Tailscale stays part of the base OS, while its auth key is still managed as an encrypted `age` secret in this repo.
|
So Tailscale stays part of the base OS, while its auth key is still managed as an encrypted `age` secret in this repo.
|
||||||
|
|
||||||
|
|
@ -150,7 +150,7 @@ So Tailscale stays part of the base OS, while its auth key is still managed as a
|
||||||
Normal iteration is just rerunning the deploy script:
|
Normal iteration is just rerunning the deploy script:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
scripts/deploy-pios-pi.sh pi@noisebridge-pi.local
|
scripts/deploy-pios-pi.sh pi@noisebell-pi.local
|
||||||
```
|
```
|
||||||
|
|
||||||
That rebuilds the binary locally, uploads a new release, refreshes secrets, and restarts the service.
|
That rebuilds the binary locally, uploads a new release, refreshes secrets, and restarts the service.
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
{
|
{
|
||||||
system.stateVersion = "24.11";
|
system.stateVersion = "24.11";
|
||||||
|
|
||||||
networking.hostName = "noisebridge-pi";
|
networking.hostName = "noisebell-pi";
|
||||||
|
|
||||||
networking.wireless = {
|
networking.wireless = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ edition = "2021"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
|
gpiod = "0.3.0"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
noisebell-common = { path = "../../remote/noisebell-common" }
|
noisebell-common = { path = "../../remote/noisebell-common" }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
|
@ -13,6 +14,5 @@ sd-notify = "0.4"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "signal", "time"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "signal", "time"] }
|
||||||
tokio-gpiod = "0.3.0"
|
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,10 @@ use axum::extract::State;
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
|
use gpiod::{Bias, Chip, Edge, EdgeDetect, Options};
|
||||||
use noisebell_common::{
|
use noisebell_common::{
|
||||||
validate_bearer, DoorStatus, PiStatusResponse, SignalLevel, WebhookPayload,
|
validate_bearer, DoorStatus, PiStatusResponse, SignalLevel, WebhookPayload,
|
||||||
};
|
};
|
||||||
use tokio_gpiod::{Bias, Chip, Edge, EdgeDetect, Options};
|
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
|
@ -135,9 +135,7 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
info!(gpio_pin, debounce_secs, port, %endpoint_url, "starting noisebell");
|
info!(gpio_pin, debounce_secs, port, %endpoint_url, "starting noisebell");
|
||||||
|
|
||||||
let chip = Chip::new("gpiochip0")
|
let chip = Chip::new("gpiochip0").context("failed to open gpiochip0")?;
|
||||||
.await
|
|
||||||
.context("failed to open gpiochip0")?;
|
|
||||||
|
|
||||||
let bias = if active_level == SignalLevel::Low {
|
let bias = if active_level == SignalLevel::Low {
|
||||||
Bias::PullUp
|
Bias::PullUp
|
||||||
|
|
@ -152,13 +150,11 @@ async fn main() -> Result<()> {
|
||||||
.consumer("noisebell");
|
.consumer("noisebell");
|
||||||
let mut inputs = chip
|
let mut inputs = chip
|
||||||
.request_lines(opts)
|
.request_lines(opts)
|
||||||
.await
|
|
||||||
.context(format!("failed to request GPIO line {gpio_pin}"))?;
|
.context(format!("failed to request GPIO line {gpio_pin}"))?;
|
||||||
|
|
||||||
// Read initial value
|
// Read initial value
|
||||||
let initial_values = inputs
|
let initial_values = inputs
|
||||||
.get_values([false])
|
.get_values([false])
|
||||||
.await
|
|
||||||
.context("failed to read initial GPIO value")?;
|
.context("failed to read initial GPIO value")?;
|
||||||
// Value is true when line is active. With Active::High (default),
|
// Value is true when line is active. With Active::High (default),
|
||||||
// true means the physical level is high.
|
// true means the physical level is high.
|
||||||
|
|
@ -187,19 +183,22 @@ async fn main() -> Result<()> {
|
||||||
// Sync initial state with the cache on startup
|
// Sync initial state with the cache on startup
|
||||||
let _ = tx.send((initial_state.as_door_status(), now));
|
let _ = tx.send((initial_state.as_door_status(), now));
|
||||||
|
|
||||||
// Spawn async edge detection task
|
// Spawn blocking edge detection task. The async tokio-gpiod path was
|
||||||
|
// returning repeated EAGAIN on Raspberry Pi OS even when no real GPIO
|
||||||
|
// error occurred.
|
||||||
let state_for_edges = state.clone();
|
let state_for_edges = state.clone();
|
||||||
let edge_tx = tx.clone();
|
let edge_tx = tx.clone();
|
||||||
let edge_handle = tokio::spawn(async move {
|
let _edge_handle = std::thread::spawn(move || {
|
||||||
let mut last_event_time = std::time::Instant::now();
|
let mut last_event_time = std::time::Instant::now() - Duration::from_secs(debounce_secs);
|
||||||
let debounce = Duration::from_secs(debounce_secs);
|
let debounce = Duration::from_secs(debounce_secs);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let event = match inputs.read_event().await {
|
let event = match inputs.read_event() {
|
||||||
Ok(event) => event,
|
Ok(event) => event,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(error = %e, "failed to read GPIO event");
|
error!(error = %e, "failed to read GPIO event");
|
||||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
std::thread::sleep(Duration::from_secs(1));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -303,7 +302,6 @@ async fn main() -> Result<()> {
|
||||||
.context("server error")?;
|
.context("server error")?;
|
||||||
|
|
||||||
info!("shutting down, draining notification queue");
|
info!("shutting down, draining notification queue");
|
||||||
edge_handle.abort();
|
|
||||||
let _ = notify_handle.await;
|
let _ = notify_handle.await;
|
||||||
|
|
||||||
info!("shutdown complete");
|
info!("shutdown complete");
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,9 @@ in
|
||||||
let
|
let
|
||||||
idx = toString (i - 1);
|
idx = toString (i - 1);
|
||||||
in
|
in
|
||||||
''export NOISEBELL_CACHE_WEBHOOK_${idx}_URL="${wh.url}"''
|
''
|
||||||
|
export NOISEBELL_CACHE_WEBHOOK_${idx}_URL="${wh.url}"
|
||||||
|
''
|
||||||
+ lib.optionalString (wh.secretFile != null) ''
|
+ lib.optionalString (wh.secretFile != null) ''
|
||||||
export NOISEBELL_CACHE_WEBHOOK_${idx}_SECRET="$(cat ${wh.secretFile})"
|
export NOISEBELL_CACHE_WEBHOOK_${idx}_SECRET="$(cat ${wh.secretFile})"
|
||||||
''
|
''
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ set -euo pipefail
|
||||||
|
|
||||||
BOOTFS=${1:-/run/media/jet/bootfs}
|
BOOTFS=${1:-/run/media/jet/bootfs}
|
||||||
ROOTFS=${2:-/run/media/jet/rootfs}
|
ROOTFS=${2:-/run/media/jet/rootfs}
|
||||||
HOSTNAME=noisebridge-pi
|
HOSTNAME=noisebell-pi
|
||||||
WIFI_SSID=Noisebridge
|
WIFI_SSID=Noisebridge
|
||||||
WIFI_PASSWORD=noisebridge
|
WIFI_PASSWORD=noisebridge
|
||||||
PI_USERNAME=pi
|
PI_USERNAME=pi
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
TARGET_HOST=${1:-root@noisebridge-pi.local}
|
TARGET_HOST=${1:-root@noisebell-pi.local}
|
||||||
|
|
||||||
exec nixos-rebuild switch --flake ".#pi" --target-host "$TARGET_HOST"
|
exec nixos-rebuild switch --flake ".#pi" --target-host "$TARGET_HOST"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
TARGET_HOST=${1:-pi@noisebridge-pi.local}
|
TARGET_HOST=${1:-pi@noisebell-pi.local}
|
||||||
|
DEPLOY_HOSTNAME=${DEPLOY_HOSTNAME:-noisebell-pi}
|
||||||
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||||
REPO_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd)
|
REPO_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd)
|
||||||
RELEASE_ID=${RELEASE_ID:-$(date +%Y%m%d-%H%M%S)}
|
RELEASE_ID=${RELEASE_ID:-$(date +%Y%m%d-%H%M%S)}
|
||||||
|
|
@ -52,12 +53,23 @@ scp "${SSH_OPTS[@]}" "$TMP_DIR/cache-to-pi-key" "$TARGET_HOST:$REMOTE_TMP_DIR/ca
|
||||||
scp "${SSH_OPTS[@]}" "$TMP_DIR/tailscale-auth-key" "$TARGET_HOST:$REMOTE_TMP_DIR/tailscale-auth-key"
|
scp "${SSH_OPTS[@]}" "$TMP_DIR/tailscale-auth-key" "$TARGET_HOST:$REMOTE_TMP_DIR/tailscale-auth-key"
|
||||||
|
|
||||||
echo "Installing service and Tailscale on $TARGET_HOST..."
|
echo "Installing service and Tailscale on $TARGET_HOST..."
|
||||||
ssh "${SSH_OPTS[@]}" "$TARGET_HOST" "REMOTE_RELEASE_DIR='$REMOTE_RELEASE_DIR' REMOTE_CURRENT_LINK='$REMOTE_CURRENT_LINK' REMOTE_TMP_DIR='$REMOTE_TMP_DIR' bash -s" <<'EOF'
|
ssh "${SSH_OPTS[@]}" "$TARGET_HOST" "DEPLOY_HOSTNAME='$DEPLOY_HOSTNAME' REMOTE_RELEASE_DIR='$REMOTE_RELEASE_DIR' REMOTE_CURRENT_LINK='$REMOTE_CURRENT_LINK' REMOTE_TMP_DIR='$REMOTE_TMP_DIR' bash -s" <<'EOF'
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y curl rsync avahi-daemon
|
sudo apt-get install -y curl rsync avahi-daemon
|
||||||
|
|
||||||
|
sudo hostnamectl set-hostname "$DEPLOY_HOSTNAME"
|
||||||
|
sudo tee /etc/hostname >/dev/null <<<"$DEPLOY_HOSTNAME"
|
||||||
|
sudo tee /etc/hosts >/dev/null <<HOSTSEOF
|
||||||
|
127.0.0.1 localhost
|
||||||
|
::1 localhost ip6-localhost ip6-loopback
|
||||||
|
ff02::1 ip6-allnodes
|
||||||
|
ff02::2 ip6-allrouters
|
||||||
|
|
||||||
|
127.0.1.1 $DEPLOY_HOSTNAME
|
||||||
|
HOSTSEOF
|
||||||
|
|
||||||
if ! command -v tailscale >/dev/null 2>&1; then
|
if ! command -v tailscale >/dev/null 2>&1; then
|
||||||
curl -fsSL https://tailscale.com/install.sh | sh
|
curl -fsSL https://tailscale.com/install.sh | sh
|
||||||
fi
|
fi
|
||||||
|
|
@ -84,7 +96,7 @@ RUST_LOG=info
|
||||||
ENVEOF
|
ENVEOF
|
||||||
sudo chmod 600 /etc/noisebell/noisebell.env
|
sudo chmod 600 /etc/noisebell/noisebell.env
|
||||||
|
|
||||||
sudo tee /etc/systemd/system/noisebell.service >/dev/null <<UNITEOF
|
sudo tee /etc/systemd/system/noisebell.service >/dev/null <<'UNITEOF'
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Noisebell GPIO door monitor
|
Description=Noisebell GPIO door monitor
|
||||||
After=network-online.target tailscaled.service
|
After=network-online.target tailscaled.service
|
||||||
|
|
@ -94,7 +106,7 @@ Wants=network-online.target
|
||||||
Type=notify
|
Type=notify
|
||||||
NotifyAccess=all
|
NotifyAccess=all
|
||||||
EnvironmentFile=/etc/noisebell/noisebell.env
|
EnvironmentFile=/etc/noisebell/noisebell.env
|
||||||
ExecStart=/bin/bash -lc 'export NOISEBELL_API_KEY="$$(cat /etc/noisebell/pi-to-cache-key)"; export NOISEBELL_INBOUND_API_KEY="$$(cat /etc/noisebell/cache-to-pi-key)"; exec ${REMOTE_CURRENT_LINK}/noisebell'
|
ExecStart=/bin/bash -lc 'export NOISEBELL_API_KEY="$$(cat /etc/noisebell/pi-to-cache-key)"; export NOISEBELL_INBOUND_API_KEY="$$(cat /etc/noisebell/cache-to-pi-key)"; exec /opt/noisebell/current/noisebell'
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
WatchdogSec=30
|
WatchdogSec=30
|
||||||
|
|
@ -105,9 +117,11 @@ UNITEOF
|
||||||
|
|
||||||
sudo ln -sfn "$REMOTE_RELEASE_DIR" "$REMOTE_CURRENT_LINK"
|
sudo ln -sfn "$REMOTE_RELEASE_DIR" "$REMOTE_CURRENT_LINK"
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable --now noisebell.service
|
sudo systemctl enable noisebell.service
|
||||||
|
sudo systemctl restart noisebell.service
|
||||||
|
sudo systemctl restart avahi-daemon
|
||||||
|
|
||||||
sudo tailscale up --auth-key="$(sudo cat /etc/noisebell/tailscale-auth-key)" --hostname=noisebridge-pi || true
|
sudo tailscale up --auth-key="$(sudo cat /etc/noisebell/tailscale-auth-key)" --hostname=noisebell-pi || true
|
||||||
|
|
||||||
rmdir "$REMOTE_TMP_DIR" 2>/dev/null || true
|
rmdir "$REMOTE_TMP_DIR" 2>/dev/null || true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
age-encryption.org/v1
|
age-encryption.org/v1
|
||||||
-> ssh-ed25519 Ziw7aw uacUhdU1sHIkFBWH8Fs05kA7Jq8UDuaGajVcnl6x8Xw
|
-> ssh-ed25519 Ziw7aw Wx6m6fWrZstI1M3mFySXEtCEeiYOK3EB8xUVLKe8my4
|
||||||
ypVbx8PvWHrzgt0kfsOiTFxf/QBQS75SgFusWFdbNQw
|
T0Evdcs7+hsWYU0M2AEWbGCtdOwHNHgk/bBXZ0jpPg4
|
||||||
-> ssh-ed25519 NFB4qA Eux3ByEKoh7oDDob17R6q+nBevoOVt+Rll24+3O9l2Y
|
-> ssh-ed25519 NFB4qA KlrsRc4Us/7WCoCk3hYNVvmeNYvfMH4hOuXAkLFipkw
|
||||||
hgRHNLvRSLzHHtnGIdLESQNYgJqhjk1nDVp2rxlCu+A
|
y/rCNHka6HDr5HdfMazlqaebcBO0K50rzcb3igcMxpw
|
||||||
-> X25519 gTGkOm0qOJzOcPSsXDh4x7mYVAwB8ImhZNwJuNisCQg
|
-> X25519 XTXs2qhJK1noZZtCHCol6IlN48s3nDOqIHX86PmQo2o
|
||||||
TmHdBGkyFtAK+SEWYU97GKK75LnobLOwIt6r15NQB2o
|
eHxpTg3QsTd3EzLUQAecNtGI7+NvP3zxFhUd8zHTuvQ
|
||||||
--- k+U0bzyk8gTo0tcBgrrSRpPqM6OdvrDmDV4AeAuzQl0
|
--- mFSpkYW6U5vQaH+a3fqVW5/ODOZwounsybqkLQoLqY0
|
||||||
'Śö5Y/Â:Ş$˙łtI´Ŕ®äçsČLŐE4tDĄEaDüŮą5űQ&’ŻkÄz—†đěó{DłM™äŻAşf|žĎo»˝»đ˘##ĎMˇď™˙t5ü(ÉÁ˘`žţ
|
yqÙ¶GŠŒ
|
||||||
|
ƵM“V=ÙÚXI£cÕò<C395>|³,ìÊQQÁð|×É<C397>±ÅÄŽ×y=¶¬÷ê+.·x«·“úRlÉÔóË4´Yï_N©é
Á&—0TVƒß,X@½4ª7
|
||||||
Loading…
Add table
Add a link
Reference in a new issue