mirror of
git://f0xx.org/android_cast
synced 2026-07-29 04:18:09 +03:00
bastion RSSH + other changes
This commit is contained in:
@@ -71,6 +71,9 @@ return [
|
||||
'local_forward_port' => 8022,
|
||||
'remote_port_min' => 18000,
|
||||
'remote_port_max' => 18999,
|
||||
// Lab/prod: create ephemeral Match User ra-* on BE sshd (see scripts/rssh_bastion_user.sh)
|
||||
'provision_users' => false,
|
||||
'provision_script' => '', // default: backend/scripts/rssh_bastion_user.sh
|
||||
],
|
||||
],
|
||||
// Outbound mail (registration verify, password reset) — see docs/20260607-2FA-email-mobile-auth-flow.md
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# FE nginx stream — TCP proxy to BE sshd for RSSH bastion (ra.apps.f0xx.org:443).
|
||||
# Install on FE (Gentoo); BE runs openssh with Match User ra-* (see sshd_config.d/ra.conf).
|
||||
#
|
||||
# Include from nginx.conf:
|
||||
# include /etc/nginx/stream.d/rssh-bastion.conf;
|
||||
|
||||
upstream rssh_bastion_be {
|
||||
server 10.7.16.128:22; # artc0 / alpine-be — adjust for cluster/lab
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443;
|
||||
proxy_pass rssh_bastion_be;
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_timeout 24h;
|
||||
}
|
||||
@@ -598,12 +598,23 @@
|
||||
const closeBtn = canOperate()
|
||||
? '<button type="button" class="btn btn-sm" data-close-session="' + esc(s.session_id) + '" data-device="' + esc(s.device_id) + '">Close</button>'
|
||||
: '';
|
||||
let endpoint = s.endpoint || '—';
|
||||
if (s.tunnel === 'ssh_reverse' && s.rssh_remote_bind) {
|
||||
endpoint = endpoint + ' → ' + esc(s.rssh_remote_bind);
|
||||
}
|
||||
let operator = '';
|
||||
if (s.rssh_operator) {
|
||||
operator =
|
||||
'<div class="ra-rssh-cmds muted"><code>' +
|
||||
esc(s.rssh_operator.shell || '') +
|
||||
'</code></div>';
|
||||
}
|
||||
tr.innerHTML =
|
||||
'<td><code>' + esc(s.session_id) + '</code></td>' +
|
||||
'<td><code>' + esc(s.device_id) + '</code></td>' +
|
||||
'<td>' + esc(s.status) + '</td>' +
|
||||
'<td>' + esc(s.tunnel) + '</td>' +
|
||||
'<td>' + esc(s.endpoint || '—') + '</td>' +
|
||||
'<td>' + endpoint + operator + '</td>' +
|
||||
'<td>' + closeBtn + '</td>';
|
||||
aBody.appendChild(tr);
|
||||
});
|
||||
|
||||
59
examples/crash_reporter/backend/scripts/rssh_bastion_user.sh
Executable file
59
examples/crash_reporter/backend/scripts/rssh_bastion_user.sh
Executable file
@@ -0,0 +1,59 @@
|
||||
#!/bin/sh
|
||||
# Ephemeral RSSH bastion users (Match User ra-* in sshd_config.d/ra.conf).
|
||||
# Usage: rssh_bastion_user.sh add|remove USERNAME [PASSWORD]
|
||||
set -eu
|
||||
|
||||
ACTION="${1:-}"
|
||||
USER="${2:-}"
|
||||
PASS="${3:-}"
|
||||
|
||||
die() {
|
||||
echo "rssh_bastion_user: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
validate_user() {
|
||||
case "$USER" in
|
||||
ra-*)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
die "invalid username (expected ra-* prefix)"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
cmd_add() {
|
||||
validate_user
|
||||
[ -n "$PASS" ] || die "password required for add"
|
||||
if id "$USER" >/dev/null 2>&1; then
|
||||
echo "$USER:$PASS" | chpasswd
|
||||
exit 0
|
||||
fi
|
||||
if command -v adduser >/dev/null 2>&1; then
|
||||
adduser -D -h /dev/null -s /bin/sh "$USER"
|
||||
elif command -v useradd >/dev/null 2>&1; then
|
||||
useradd -M -s /bin/sh "$USER"
|
||||
else
|
||||
die "no adduser/useradd"
|
||||
fi
|
||||
echo "$USER:$PASS" | chpasswd
|
||||
}
|
||||
|
||||
cmd_remove() {
|
||||
validate_user
|
||||
if ! id "$USER" >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
if command -v deluser >/dev/null 2>&1; then
|
||||
deluser "$USER" 2>/dev/null || true
|
||||
elif command -v userdel >/dev/null 2>&1; then
|
||||
userdel "$USER" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
case "$ACTION" in
|
||||
add) cmd_add ;;
|
||||
remove) cmd_remove ;;
|
||||
*) die "usage: $0 add|remove USERNAME [PASSWORD]" ;;
|
||||
esac
|
||||
@@ -25,6 +25,6 @@ body=$(printf '{"heartbeat":{"type":"ra","status":"enable","device_id":"%s","ran
|
||||
curl -sf -X POST -H 'Content-Type: application/json' -d "$body" "$(ra_heartbeat_url)" > /tmp/rssh-connect.json
|
||||
grep -q '"action":"connect"' /tmp/rssh-connect.json && echo OK connect
|
||||
grep -q 'bastion_host' /tmp/rssh-connect.json && echo OK bastion fields
|
||||
grep -q 'remote_port' /tmp/rssh-connect.json && echo OK remote_port
|
||||
grep -q 'remote_bind_port' /tmp/rssh-connect.json && echo OK remote_bind_port
|
||||
|
||||
echo "All RSSH API checks passed."
|
||||
|
||||
23
examples/crash_reporter/backend/scripts/test_rssh_unit.sh
Executable file
23
examples/crash_reporter/backend/scripts/test_rssh_unit.sh
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Unit checks for RsshSessionProvisioner + RsshBastionProvisioner (no DB).
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
export CRASHES_CONFIG="${CRASHES_CONFIG:-$ROOT/config/config.example.php}"
|
||||
|
||||
php -r '
|
||||
require "'"$ROOT"'/src/bootstrap.php";
|
||||
$alloc = RsshSessionProvisioner::allocate("test-session-abc");
|
||||
assert($alloc["username"] !== "");
|
||||
assert($alloc["remote_bind_port"] >= 18000);
|
||||
assert($alloc["local_forward_port"] === 8022);
|
||||
$cmds = RsshBastionProvisioner::operatorCommands([
|
||||
"rssh_username" => $alloc["username"],
|
||||
"rssh_remote_port" => $alloc["remote_bind_port"],
|
||||
]);
|
||||
assert(isset($cmds["shell"]));
|
||||
assert(str_contains($cmds["shell"], (string) $alloc["remote_bind_port"]));
|
||||
echo "OK RsshSessionProvisioner + operatorCommands\n";
|
||||
'
|
||||
|
||||
echo "All RSSH PHP unit checks passed."
|
||||
@@ -473,6 +473,10 @@ final class RemoteAccessRepository {
|
||||
];
|
||||
}
|
||||
$alloc = RsshSessionProvisioner::allocate($sessionId);
|
||||
$bastion = RsshBastionProvisioner::addUser($alloc['username'], $alloc['password']);
|
||||
if (!$bastion['ok']) {
|
||||
throw new RuntimeException((string) ($bastion['error'] ?? 'bastion_user_add_failed'));
|
||||
}
|
||||
Database::pdo()->prepare(
|
||||
'UPDATE remote_access_sessions SET rssh_bastion_host = ?, rssh_bastion_port = ?, rssh_username = ?, rssh_secret = ?, rssh_remote_port = ?, rssh_local_port = ? WHERE session_id = ?'
|
||||
)->execute([
|
||||
@@ -572,14 +576,18 @@ final class RemoteAccessRepository {
|
||||
|
||||
public static function closeSession(string $sessionId, string $status, string $reason): void {
|
||||
$st = Database::pdo()->prepare(
|
||||
'SELECT tunnel, wg_client_public_key FROM remote_access_sessions WHERE session_id = ? LIMIT 1'
|
||||
'SELECT tunnel, wg_client_public_key, rssh_username FROM remote_access_sessions WHERE session_id = ? LIMIT 1'
|
||||
);
|
||||
$st->execute([$sessionId]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if (is_array($row) && ($row['tunnel'] ?? 'wireguard') === 'wireguard') {
|
||||
$pub = $row['wg_client_public_key'] ?? '';
|
||||
if (is_string($pub) && $pub !== '') {
|
||||
WireGuardPeerProvisioner::removeClientPeer($pub);
|
||||
if (is_array($row)) {
|
||||
if (($row['tunnel'] ?? 'wireguard') === 'wireguard') {
|
||||
$pub = $row['wg_client_public_key'] ?? '';
|
||||
if (is_string($pub) && $pub !== '') {
|
||||
WireGuardPeerProvisioner::removeClientPeer($pub);
|
||||
}
|
||||
} elseif (($row['tunnel'] ?? '') === 'ssh_reverse') {
|
||||
RsshBastionProvisioner::removeUser((string) ($row['rssh_username'] ?? ''));
|
||||
}
|
||||
}
|
||||
$now = self::nowSql();
|
||||
@@ -857,12 +865,27 @@ final class RemoteAccessRepository {
|
||||
|
||||
return [
|
||||
'devices' => self::listDevicesForDashboard($issuesBase, $projectBase),
|
||||
'active_sessions' => $active,
|
||||
'inactive_sessions' => array_slice($inactive, 0, 50),
|
||||
'active_sessions' => array_map([self::class, 'enrichSessionRow'], $active),
|
||||
'inactive_sessions' => array_map([self::class, 'enrichSessionRow'], array_slice($inactive, 0, 50)),
|
||||
'recent_events' => self::listEvents(30),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row @return array<string, mixed> */
|
||||
private static function enrichSessionRow(array $row): array {
|
||||
if (($row['tunnel'] ?? '') === 'ssh_reverse') {
|
||||
$cmds = RsshBastionProvisioner::operatorCommands($row);
|
||||
if ($cmds !== []) {
|
||||
$row['rssh_operator'] = $cmds;
|
||||
}
|
||||
$remote = (int) ($row['rssh_remote_port'] ?? 0);
|
||||
if ($remote > 0) {
|
||||
$row['rssh_remote_bind'] = '127.0.0.1:' . $remote;
|
||||
}
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> */
|
||||
public static function listDevicesForDashboard(string $issuesBase, string $projectBase): array {
|
||||
$devices = self::listDevices();
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Ephemeral Linux users on the RSSH bastion (sshd Match User ra-*). */
|
||||
final class RsshBastionProvisioner {
|
||||
private function __construct() {
|
||||
}
|
||||
|
||||
public static function isEnabled(): bool {
|
||||
return (bool) cfg('remote_access.rssh.provision_users', false);
|
||||
}
|
||||
|
||||
public static function scriptPath(): string {
|
||||
$configured = trim((string) cfg('remote_access.rssh.provision_script', ''));
|
||||
if ($configured !== '') {
|
||||
return $configured;
|
||||
}
|
||||
return dirname(__DIR__) . '/scripts/rssh_bastion_user.sh';
|
||||
}
|
||||
|
||||
/** @return array{ok:bool,error?:string} */
|
||||
public static function addUser(string $username, string $password): array {
|
||||
$user = self::normalizeUsername($username);
|
||||
if ($user === '') {
|
||||
return ['ok' => false, 'error' => 'invalid_username'];
|
||||
}
|
||||
if (!self::isEnabled()) {
|
||||
return ['ok' => true];
|
||||
}
|
||||
$code = self::runScript('add', $user, $password);
|
||||
if ($code !== 0) {
|
||||
error_log('RsshBastionProvisioner add failed for ' . $user . ' exit=' . $code);
|
||||
return ['ok' => false, 'error' => 'bastion_user_add_failed'];
|
||||
}
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
public static function removeUser(?string $username): void {
|
||||
$user = self::normalizeUsername((string) $username);
|
||||
if ($user === '' || !self::isEnabled()) {
|
||||
return;
|
||||
}
|
||||
$code = self::runScript('remove', $user, '');
|
||||
if ($code !== 0) {
|
||||
error_log('RsshBastionProvisioner remove failed for ' . $user . ' exit=' . $code);
|
||||
}
|
||||
}
|
||||
|
||||
/** Operator command lines once device has connected reverse forward. */
|
||||
public static function operatorCommands(array $session): array {
|
||||
$username = trim((string) ($session['rssh_username'] ?? ''));
|
||||
$remotePort = (int) ($session['rssh_remote_port'] ?? 0);
|
||||
if ($username === '' || $remotePort <= 0) {
|
||||
return [];
|
||||
}
|
||||
$host = '127.0.0.1';
|
||||
return [
|
||||
'shell' => sprintf('ssh -p %d %s@%s', $remotePort, $username, $host),
|
||||
'sftp' => sprintf('sftp -P %d %s@%s', $remotePort, $username, $host),
|
||||
'scp_example' => sprintf('scp -P %d %s@%s:/path/on/device ./', $remotePort, $username, $host),
|
||||
];
|
||||
}
|
||||
|
||||
private static function normalizeUsername(string $username): string {
|
||||
$user = trim($username);
|
||||
if ($user === '' || !preg_match('/^ra-[a-zA-Z0-9_-]{1,30}$/', $user)) {
|
||||
return '';
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
|
||||
private static function runScript(string $action, string $username, string $password): int {
|
||||
$script = self::scriptPath();
|
||||
if (!is_file($script) || !is_executable($script)) {
|
||||
error_log('RsshBastionProvisioner: script missing or not executable: ' . $script);
|
||||
return 127;
|
||||
}
|
||||
$cmd = escapeshellarg($script) . ' ' . escapeshellarg($action) . ' ' . escapeshellarg($username);
|
||||
if ($action === 'add') {
|
||||
$cmd .= ' ' . escapeshellarg($password);
|
||||
}
|
||||
$out = [];
|
||||
exec($cmd . ' 2>&1', $out, $code);
|
||||
if ($code !== 0 && $out !== []) {
|
||||
error_log('RsshBastionProvisioner: ' . implode("\n", $out));
|
||||
}
|
||||
return (int) $code;
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,7 @@ require_once __DIR__ . '/WireGuardPeerProvisioner.php';
|
||||
require_once __DIR__ . '/WireGuardAddressPool.php';
|
||||
require_once __DIR__ . '/WireGuardTrafficStats.php';
|
||||
require_once __DIR__ . '/RsshSessionProvisioner.php';
|
||||
require_once __DIR__ . '/RsshBastionProvisioner.php';
|
||||
require_once __DIR__ . '/AnalyticsHead.php';
|
||||
|
||||
function cfg(string $key, $default = null) {
|
||||
|
||||
42
examples/rssh/linux-sim/README.md
Normal file
42
examples/rssh/linux-sim/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Linux RSSH device/operator simulation
|
||||
|
||||
Simulates the **Android RSSH path** on a laptop with real OpenSSH client + optional local `sshd`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `curl`, `jq`, `sshpass` (for non-interactive sim)
|
||||
- Backend reachable (`CRASHES_BASE` or default from `ra_lib.sh`)
|
||||
- Admin whitelist + open session for the simulated `device_id`
|
||||
|
||||
## Device sim (heartbeat + reverse forward)
|
||||
|
||||
```bash
|
||||
export CRASHES_BASE="http://cast01.intra.raptor.org/app/androidcast_project/crashes"
|
||||
export RA_DEVICE_ID="linux-rssh-lab-01"
|
||||
|
||||
# 1) First run registers heartbeat (wait)
|
||||
./ra_device_sim.sh
|
||||
|
||||
# 2) Whitelist device + open session in admin UI, then:
|
||||
export RA_SKIP_ADMIN=1
|
||||
./ra_device_sim.sh
|
||||
```
|
||||
|
||||
## Operator sim (on bastion / BE)
|
||||
|
||||
After device sim prints `REMOTE_BIND_PORT` and username:
|
||||
|
||||
```bash
|
||||
./ra_operator_connect.sh 18022 ra-SESSIONID shell
|
||||
./ra_operator_connect.sh 18022 ra-SESSIONID sftp
|
||||
```
|
||||
|
||||
## Android parity
|
||||
|
||||
| Android | Linux sim |
|
||||
|---------|-----------|
|
||||
| `RemoteAccessService` poll | `ra_ra_post` via `ra_lib.sh` |
|
||||
| `RsshLocalSshServer` :8022 | local `sshd -p 8022` |
|
||||
| `ReverseSshTunnelBridge` JSch `-R` | `ssh -N -R …` |
|
||||
|
||||
See [REMOTE_ACCESS_IMPL.md](../../../docs/REMOTE_ACCESS_IMPL.md) and [20260602_REVERSE_SSH_proposals_summary.md](../../../docs/20260602_REVERSE_SSH_proposals_summary.md).
|
||||
75
examples/rssh/linux-sim/ra_device_sim.sh
Executable file
75
examples/rssh/linux-sim/ra_device_sim.sh
Executable file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# Linux laptop simulates Android RSSH device: heartbeat poll + outbound -R + local sshd on 8022.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
BE_SCRIPTS="$(cd "$(dirname "$0")/../../crash_reporter/backend/scripts" && pwd)"
|
||||
# shellcheck source=../../crash_reporter/backend/scripts/ra_lib.sh
|
||||
source "$BE_SCRIPTS/ra_lib.sh"
|
||||
|
||||
DEVICE_ID="${RA_DEVICE_ID:-linux-rssh-$(hostname -s)-$$}"
|
||||
RANDOM_ID="${RA_RANDOM:-rssh-linux-$(date +%s)}"
|
||||
LOCAL_PORT="${RA_LOCAL_PORT:-8022}"
|
||||
WORKDIR="${RA_SIM_WORKDIR:-/tmp/rssh-sim-$$}"
|
||||
SSH_PID=""
|
||||
DB_PID=""
|
||||
|
||||
cleanup() {
|
||||
[ -n "$SSH_PID" ] && kill "$SSH_PID" 2>/dev/null || true
|
||||
[ -n "$DB_PID" ] && kill "$DB_PID" 2>/dev/null || true
|
||||
rm -rf "$WORKDIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$WORKDIR"
|
||||
HOST_KEY="$WORKDIR/host_key"
|
||||
|
||||
if ! command -v ssh-keygen >/dev/null 2>&1; then
|
||||
echo "openssh client required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== local device SSH on 127.0.0.1:${LOCAL_PORT} =="
|
||||
ssh-keygen -t ed25519 -f "$HOST_KEY" -N "" -q
|
||||
/usr/sbin/sshd -D -f /dev/null -h "$HOST_KEY" -p "$LOCAL_PORT" -o AuthorizedKeysFile=/dev/null \
|
||||
-o PasswordAuthentication=yes -o PermitRootLogin=no -o UsePAM=no \
|
||||
-o AllowUsers="${RA_SSH_USER:-rssh-sim}" 2>/dev/null &
|
||||
DB_PID=$!
|
||||
sleep 0.5
|
||||
|
||||
echo "== heartbeat enable (tunnel_mode=rssh) =="
|
||||
export RA_TUNNEL_MODE=rssh
|
||||
ra_ra_post enable "$DEVICE_ID" "$RANDOM_ID" "linux-sim/1.0" >/tmp/rssh-sim-wait.json
|
||||
grep -q '"action":"wait"' /tmp/rssh-sim-wait.json && echo OK wait
|
||||
|
||||
echo "Operator must whitelist + open session in admin UI, then re-run with RA_SKIP_ADMIN=1"
|
||||
if [[ "${RA_SKIP_ADMIN:-}" != "1" ]]; then
|
||||
echo "Set RA_SKIP_ADMIN=1 after whitelisting device_id=$DEVICE_ID"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "== heartbeat connect =="
|
||||
ra_ra_post enable "$DEVICE_ID" "$RANDOM_ID-b" "linux-sim/1.0" >/tmp/rssh-sim-connect.json
|
||||
grep -q '"action":"connect"' /tmp/rssh-sim-connect.json || { cat /tmp/rssh-sim-connect.json; exit 1; }
|
||||
echo OK connect payload
|
||||
|
||||
BASTION="$(ra_json -r '.credentials.bastion_host // empty' /tmp/rssh-sim-connect.json)"
|
||||
BPORT="$(ra_json -r '.credentials.bastion_port // 443' /tmp/rssh-sim-connect.json)"
|
||||
USER="$(ra_json -r '.credentials.username // empty' /tmp/rssh-sim-connect.json)"
|
||||
PASS="$(ra_json -r '.credentials.password // empty' /tmp/rssh-sim-connect.json)"
|
||||
RPORT="$(ra_json -r '.credentials.remote_bind_port // 0' /tmp/rssh-sim-connect.json)"
|
||||
|
||||
[ -n "$BASTION" ] && [ -n "$USER" ] && [ "$RPORT" -gt 0 ] || { echo "missing credentials"; exit 1; }
|
||||
|
||||
echo "== reverse SSH -R 127.0.0.1:${RPORT}:127.0.0.1:${LOCAL_PORT} =="
|
||||
sshpass -p "$PASS" ssh -N \
|
||||
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
-p "$BPORT" -R "127.0.0.1:${RPORT}:127.0.0.1:${LOCAL_PORT}" \
|
||||
"${USER}@${BASTION}" &
|
||||
SSH_PID=$!
|
||||
sleep 2
|
||||
|
||||
echo "Tunnel up. Operator on bastion:"
|
||||
echo " ssh -p ${RPORT} ${USER}@127.0.0.1"
|
||||
echo "Press Ctrl+C to stop."
|
||||
wait "$SSH_PID"
|
||||
25
examples/rssh/linux-sim/ra_operator_connect.sh
Executable file
25
examples/rssh/linux-sim/ra_operator_connect.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# Operator-side: SSH/SFTP to forwarded port on bastion (run on BE or jump host).
|
||||
set -euo pipefail
|
||||
|
||||
RPORT="${1:-}"
|
||||
USER="${2:-}"
|
||||
MODE="${3:-shell}"
|
||||
|
||||
if [ -z "$RPORT" ] || [ -z "$USER" ]; then
|
||||
echo "usage: $0 REMOTE_BIND_PORT RA_USERNAME [shell|sftp]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$MODE" in
|
||||
shell)
|
||||
exec ssh -p "$RPORT" -o StrictHostKeyChecking=no "${USER}@127.0.0.1"
|
||||
;;
|
||||
sftp)
|
||||
exec sftp -P "$RPORT" -o StrictHostKeyChecking=no "${USER}@127.0.0.1"
|
||||
;;
|
||||
*)
|
||||
echo "unknown mode: $MODE" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user