pc-control 远程开关机部署教程

在一台常开的 Linux 机器上跑一个 Flask 小服务,手机/浏览器一键对局域网里的 Windows PC 执行 开机(WOL)/ 关机 / 重启(SSH)。核心卖点:全程不需要固定 IP——PC 的 IP 按 MAC 动态反查,服务入口用 mDNS 域名,换路由器、换网段、IP 怎么变都不用改任何配置。

一、最终效果

操作 触发方式 背后原理
开机 网页按钮 / iOS 快捷指令 WOL 魔术包(二层广播)
关机 同上 SSH 执行 shutdown /s
重启 同上 SSH 执行 shutdown /r
  • 网页:http://<服务器主机名>.local:5000/,三个按钮,结果直接显示在页面里
  • 快捷指令:GET 请求返回纯文本(成功给"开机指令已发送",失败给具体原因),适合"显示通知"

二、原理:先懂再装

2.1 整体架构

手机(快捷指令 / 浏览器)
   │  http://<主机名>.local:5000/        ← mDNS 域名,服务器 IP 变了也不怕
   ▼
Linux 服务器(常开,与 PC 同一路由器/网段)
   pc-control(Flask + waitress,systemd 用户服务)
   │
   ├─ 开机:wakeonlan 发魔术包 → 二层广播 → PC 网卡唤醒开机
   │
   └─ 关机/重启:
        ① 按 MAC 反查 PC 当前 IP(先缓存,再 ARP 全段扫描)
        ② sshpass + ssh 连上 PC 执行 shutdown
   ▲
   │  ARP:IP ↔ MAC 映射(内核层完成,防火墙拦 ping 也拦不住)
PC(Windows,有线网络,开启 OpenSSH + WOL)

2.2 WOL 魔术包:为什么能"隔空开机"

  • PC 关机后只要插着电源,网卡仍有微弱待机电,持续监听总线上属于自己的"魔术包"
  • 魔术包格式:连续 6 字节 FF + 目标 MAC 重复 16 次,发往广播地址(如 192.168.8.255)
  • 关键限制:广播是二层的,不跨路由器——发魔术包的机器必须和 PC 在同一个网段
  • 这也是 PC 要用有线网络的原因:Wi-Fi 网卡关机后基本不监听魔术包

2.3 ARP:为什么不需要固定 IP

  • 局域网里真正定位一台设备靠的是 MAC(网卡出厂地址,基本不变);IP 只是会变的"门牌号"
  • 任何 IP 通信前,内核都会先广播 ARP 问"这个 IP 谁在用",对方应答 MAC。这一步在内核层完成,Windows 防火墙拦 ping 也拦不住 ARP
  • 所以:只要 PC 开着,对全网段并发 ping 一轮(哪怕 ICMP 全被防火墙吞掉),ARP 表里就一定有 PC 的 MAC↔IP 记录,ip neigh 一查便知
  • 服务里的解析顺序:上次缓存的 IP(快)→ 不对就全网段扫描(约 5 秒)→ 按 MAC 反查 → 更新缓存
  • MAC 是整套方案唯一的稳定锚点:配置里只填 MAC,IP 永远动态解析

2.4 mDNS(.local):为什么入口地址也不会失效

  • 手机访问的是 http://<主机名>.local:5000/,不是 IP
  • mDNS 原理:局域网组播(224.0.0.251:5353)问"谁叫这个名字",服务器上的 avahi 守护进程应答当前 IP
  • 服务器 IP 变了?avahi 自动更新广播记录,零维护
  • 不经过路由器 DNS、不依赖任何云服务;iOS / macOS 原生支持 .local
  • 前提:手机和服务器在同一局域网。出门在外会失效——那是 Tailscale 的场景(见第十节)

2.5 双接口设计

接口 方法 返回 给谁用
/api/wake /api/shutdown /api/reboot POST JSON 网页 fetch
/wake /shutdown /reboot GET 纯文本 iOS 快捷指令

旧 GET 接口保留兼容。网页按钮走 POST+JSON(页面 JS 解析 ok 字段着色),快捷指令走 GET+纯文本(通知里直接可读)。

三、准备清单

  • 一台常开的 Linux 机器(Debian/Ubuntu 均可),和 PC 接在同一路由器下
  • Windows PC,有线连接路由器
  • PC 上有可登录的本地账号(SSH 用)
  • 手机和服务器在同一局域网(验收用)

四、Windows PC 端配置

4.1 安装 OpenSSH Server(关机/重启的通道)

管理员 PowerShell:

# 安装 OpenSSH 服务器
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
# 设为自启并立即启动
Set-Service -Name sshd -StartupType Automatic
Start-Service sshd
# 确认防火墙规则(安装时一般已自动添加)
Get-NetFirewallRule -Name *OpenSSH-Server* | Select Name, Enabled, Direction

验证:在服务器上 ssh 用户名@PC的IP 能登录即可。

4.2 开启 WOL(开机的通道)

  1. BIOS/UEFI:找 Wake on LAN / PCI-E Device Wake / Resume by LAN 类选项设为 Enabled;如有 ErP 选项设为 Disabled(ErP 会切断待机电,网卡没电就监听不了)
  2. Windows 网卡设置:设备管理器 → 网络适配器 → 有线网卡 → 属性:
    • "电源管理"页:勾选"允许此设备唤醒计算机"和"只允许幻数据包唤醒计算机"
    • "高级"页:Wake on Magic Packet / "关机 网络唤醒"类选项设为 Enabled
  3. 快速启动:控制面板 → 电源选项 → "选择电源按钮的功能"。如果 WOL 不生效,尝试关闭"启用快速启动"(快速启动的"关机"其实是深度休眠,部分网卡/驱动组合下魔术包叫不醒)

4.3 查有线网卡 MAC

getmac /v

找"物理地址"里对应以太网(不是 Wi-Fi)的那一行,形如 A0-AD-9F-59-B4-69。这个 MAC 是整套方案的唯一稳定标识。

五、服务器端部署

以 Debian 12/13 为例,用户名按 mango 示例,照抄时替换成自己的。

5.1 装系统依赖

sudo apt update
sudo apt install -y python3-venv wakeonlan sshpass avahi-daemon avahi-utils
  • wakeonlan:发魔术包的命令行工具
  • sshpass:让 ssh 能用密码非交互登录(脚本里用)
  • avahi-daemon:mDNS 守护进程,负责广播 .local 域名

5.2 建目录 + 虚拟环境

mkdir -p ~/code/pc-control && cd ~/code/pc-control
python3 -m venv .venv
.venv/bin/pip install flask waitress

5.3 写入 app.py

完整代码如下,保存为 ~/code/pc-control/app.py。只需要改顶部配置区:MAC 换成 4.3 查到的,账号密码换成 Windows 的;网段和广播地址留空即自动探测。

from datetime import datetime, timezone
import ipaddress
import json
import logging
import re
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

from flask import Flask, Response, jsonify, render_template_string

app = Flask(__name__)
logging.basicConfig(level=logging.INFO)

# ====== 配置区(只在这里改!)======
PC_CONFIG = {
    "name": "我的电脑",
    "mac": "A0:AD:9F:59:B4:69",  # 唯一稳定标识:开机靠它,IP 也按它动态反查
    "subnet": "",                # 例如 "192.168.8.0/24";留空=自动探测本机所在网段
    "broadcast": "",             # 例如 "192.168.8.255";留空=由网段自动推算
    "username": "你的Windows用户名",
    "password": "你的Windows密码"
}
# ==================================

CACHE_FILE = Path(__file__).resolve().parent / "last_ip.json"

def _api_response(action, action_name, *, success, message=None, error=None, status_code=200):
    """Return a stable, shortcut-friendly response payload (dict + HTTP status)."""
    payload = {
        'ok': success,
        'status': 'success' if success else 'error',
        'action': action,
        'action_name': action_name,
        'message': message if success else None,
        'error': error if not success else None,
        'timestamp': datetime.now(timezone.utc).isoformat(),
    }
    return payload, status_code


def _json(payload, code):
    return jsonify(payload), code


def _text(payload, code):
    """快捷指令用的纯文本返回:成功给 message,失败给 error。"""
    body = payload.get('message') if payload.get('ok') else payload.get('error')
    return Response(body or ('操作已发送' if payload.get('ok') else '操作失败'),
                    status=code, mimetype='text/plain')


# ---------- 按 MAC 动态解析 PC 的 IP(免固定 IP) ----------

def _run(cmd, timeout=10):
    return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)


def _norm_mac(mac):
    return mac.replace('-', ':').lower()


def _detect_subnet():
    """取默认路由网卡上的 kernel 直连路由作为局域网网段,如 192.168.8.0/24。"""
    routes = _run(['ip', '-4', 'route', 'show']).stdout
    m = re.search(r'^default .*?dev (\S+)', routes, re.M)
    if not m:
        return None
    dev = m.group(1)
    for line in routes.splitlines():
        parts = line.split()
        if (len(parts) >= 4 and parts[0].endswith('/24')
                and f'dev {dev}' in line and 'proto kernel' in line):
            return parts[0]
    return None


def _get_subnet():
    raw = PC_CONFIG.get('subnet') or _detect_subnet()
    if not raw:
        return None
    net = ipaddress.ip_network(raw, strict=False)
    # 网段太大不做扫描(避免误配成 /16 之类)
    return net if net.num_addresses <= 1024 else None


def _get_broadcast():
    if PC_CONFIG.get('broadcast'):
        return PC_CONFIG['broadcast']
    net = _get_subnet()
    return str(net.broadcast_address) if net else '255.255.255.255'


def _arp_of_ip(ip):
    out = _run(['ip', '-4', 'neigh', 'show', ip]).stdout
    m = re.search(r'lladdr (\S+)', out)
    return _norm_mac(m.group(1)) if m else None


def _mac_to_ip(net):
    """在 ARP 表(ip neigh)里按 MAC 反查 IP,限定在目标网段内。"""
    target = _norm_mac(PC_CONFIG['mac'])
    out = _run(['ip', '-4', 'neigh', 'show']).stdout
    for line in out.splitlines():
        parts = line.split()
        if 'lladdr' not in parts:
            continue
        mac = parts[parts.index('lladdr') + 1]
        if _norm_mac(mac) != target:
            continue
        try:
            if ipaddress.ip_address(parts[0]) in net:
                return parts[0]
        except ValueError:
            continue
    return None


def _sweep(net):
    """并发 ping 全网段触发 ARP 解析;即使 ICMP 被防火墙拦,ARP 也会在内核层完成。"""
    def probe(ip):
        try:
            _run(['ping', '-c1', '-W1', str(ip)], timeout=3)
        except Exception:
            pass
    with ThreadPoolExecutor(max_workers=60) as pool:
        list(pool.map(probe, net.hosts()))


def _resolve_pc_ip():
    """PC 开着就一定能按 MAC 找到 IP:先试上次缓存(快),再全网段扫描(约 5 秒)。"""
    target = _norm_mac(PC_CONFIG['mac'])
    # 1) 缓存快速路径
    try:
        cached = json.loads(CACHE_FILE.read_text()).get('ip')
    except Exception:
        cached = None
    if cached:
        _run(['ping', '-c1', '-W1', cached], timeout=3)
        if _arp_of_ip(cached) == target:
            return cached
    # 2) 全段扫描
    net = _get_subnet()
    if not net:
        return None
    _sweep(net)
    ip = _mac_to_ip(net)
    if ip:
        try:
            CACHE_FILE.write_text(json.dumps({'ip': ip, 'ts': time.time()}))
        except Exception:
            pass
    return ip


@app.route('/')
def index():
    return render_template_string('''
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>远程开关机 - {{ name }}</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background: #0c0c14;
            color: #e0e0ff;
            min-height: 100vh;
            overflow: hidden;
            display: flex;
            justify-content: center;
            align-items: center;
            position: relative;
        }

        #particles-js {
            position: absolute;
            width: 100%;
            height: 100%;
            top: 0;
            left: 0;
            z-index: 0;
        }

        .container {
            text-align: center;
            z-index: 10;
            padding: 30px;
            background: rgba(15, 15, 30, 0.7);
            backdrop-filter: blur(10px);
            border-radius: 20px;
            box-shadow: 0 0 30px rgba(100, 100, 255, 0.2);
            max-width: 90vw;
            width: 400px;
        }

        h1 {
            font-size: 2.4rem;
            margin-bottom: 30px;
            background: linear-gradient(90deg, #00c6ff, #0072ff, #7a00ff);
            -webkit-background-clip: text;
            background-clip: text;
            color: transparent;
            text-shadow: 0 0 15px rgba(0, 114, 255, 0.5);
            letter-spacing: 1px;
        }

        .btn {
            display: block;
            width: 90%;
            margin: 12px auto;
            padding: 16px;
            font-size: 1.2rem;
            font-weight: bold;
            border: none;
            border-radius: 50px;
            cursor: pointer;
            transition: all 0.3s ease;
            box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
            position: relative;
            overflow: hidden;
            color: white;
        }

        .btn::after {
            content: '';
            position: absolute;
            top: -50%;
            left: -60%;
            width: 20px;
            height: 200%;
            background: rgba(255, 255, 255, 0.3);
            transform: rotate(25deg);
            transition: all 0.6s;
        }

        .btn:hover::after {
            left: 120%;
        }

        .wake {
            background: linear-gradient(135deg, #00c853, #009624);
            box-shadow: 0 0 20px rgba(0, 200, 83, 0.4);
        }

        .shutdown {
            background: linear-gradient(135deg, #ff5252, #d32f2f);
            box-shadow: 0 0 20px rgba(255, 82, 82, 0.4);
        }

        .reboot {
            background: linear-gradient(135deg, #ffc107, #ff8f00);
            box-shadow: 0 0 20px rgba(255, 193, 7, 0.4);
        }

        .btn:hover {
            transform: translateY(-4px);
            box-shadow: 0 6px 25px rgba(0, 0, 0, 0.4);
        }

        .btn:active {
            transform: translateY(2px) scale(0.97);
            box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
        }

        .result {
            margin-top: 22px;
            min-height: 24px;
            color: #aeb4d4;
            font-size: 0.95rem;
        }

        .result.success { color: #69e89b; }
        .result.error { color: #ff8585; }

        @media (max-width: 500px) {
            h1 { font-size: 2rem; }
            .btn { padding: 14px; font-size: 1.1rem; }
        }
    </style>
</head>
<body>
    <div id="particles-js"></div>
    <div class="container">
        <h1>💻 {{ name }}</h1>
        <button class="btn wake" onclick="send('/api/wake', this)">✅ 开机 (WOL)</button>
        <button class="btn reboot" onclick="send('/api/reboot', this)">🔁 重启 (SSH)</button>
        <button class="btn shutdown" onclick="send('/api/shutdown', this)">⏹️ 关机 (SSH)</button>
        <p id="result" class="result" role="status">请选择要执行的操作</p>
    </div>

    <!-- 引入 particles.js -->
    <script src="https://cdn.jsdelivr.net/particles.js/2.0.0/particles.min.js"></script>
    <script>
        particlesJS("particles-js", {
            particles: {
                number: { value: 80, density: { enable: true, value_area: 800 } },
                color: { value: "#00bfff" },
                shape: { type: "circle" },
                opacity: { value: 0.3, random: true },
                size: { value: 3, random: true },
                line_linked: {
                    enable: true,
                    distance: 150,
                    color: "#0066ff",
                    opacity: 0.2,
                    width: 1
                },
                move: {
                    enable: true,
                    speed: 1,
                    direction: "none",
                    random: true,
                    out_mode: "out"
                }
            },
            interactivity: {
                detect_on: "canvas",
                events: {
                    onhover: { enable: true, mode: "repulse" },
                    onclick: { enable: true, mode: "push" },
                    resize: true
                }
            },
            retina_detect: true
        });
    </script>

    <script>
        async function send(url, btn) {
            const result = document.getElementById('result');
            const orig = btn.textContent;
            btn.disabled = true;
            btn.style.opacity = '0.6';
            result.className = 'result';
            result.textContent = '正在发送指令…';
            try {
                const res = await fetch(url, { method: 'POST' });
                const data = await res.json();
                result.className = 'result ' + (data.ok ? 'success' : 'error');
                result.textContent = data.ok
                    ? '✅ ' + (data.message || '操作已发送')
                    : '❌ ' + (data.error || '操作失败');
            } catch (err) {
                result.className = 'result error';
                result.textContent = '🔌 网络错误:无法连接服务器';
            } finally {
                btn.disabled = false;
                btn.style.opacity = '1';
                btn.textContent = orig;
            }
        }
    </script>
</body>
</html>
    ''', name=PC_CONFIG["name"])

def _wake_action():
    try:
        subprocess.run([
            'wakeonlan', '-i', _get_broadcast(), PC_CONFIG['mac']
        ], check=True, capture_output=True, text=True, timeout=10)
        return _api_response('wake', '开机', success=True,
                             message='开机指令已发送', status_code=202)
    except FileNotFoundError:
        return _api_response('wake', '开机', success=False,
                             error='未安装 wakeonlan,请运行: sudo apt install wakeonlan', status_code=500)
    except subprocess.CalledProcessError as e:
        err = (e.stderr or '').strip()
        return _api_response('wake', '开机', success=False,
                             error=f'wakeonlan 执行失败: {err or e}', status_code=500)
    except subprocess.TimeoutExpired:
        return _api_response('wake', '开机', success=False,
                             error='wakeonlan 执行超时', status_code=504)
    except Exception as e:
        logging.exception("WOL wake failed")
        return _api_response('wake', '开机', success=False,
                             error=f'开机失败: {e}', status_code=500)

def _ssh_shutdown_action(restart=False):
    action = '重启' if restart else '关机'
    ip = _resolve_pc_ip()
    if not ip:
        return _api_response('reboot' if restart else 'shutdown', action, success=False,
                             error=f'未找到 PC(MAC {PC_CONFIG["mac"]}):可能已关机,或与本服务不在同一网段',
                             status_code=404)
    flag = '/r' if restart else '/s'
    cmd = [
        'sshpass', '-p', PC_CONFIG['password'],
        'ssh', '-o', 'StrictHostKeyChecking=no',
             '-o', 'ConnectTimeout=8',
             '-o', 'LogLevel=ERROR',
             '-o', 'BatchMode=no',
             f"{PC_CONFIG['username']}@{ip}",
        f'shutdown {flag} /f /t 0 /d p:0:0'
    ]
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
    except FileNotFoundError:
        return _api_response('reboot' if restart else 'shutdown', action, success=False,
                             error='未安装 sshpass,请运行: sudo apt install sshpass', status_code=500)
    except subprocess.TimeoutExpired:
        return _api_response('reboot' if restart else 'shutdown', action, success=False,
                             error=f'{action}失败:SSH 连接超时(目标电脑是否已开机?网络是否通畅?)', status_code=504)
    except Exception as e:
        logging.exception(f"SSH {action} failed")
        return _api_response('reboot' if restart else 'shutdown', action, success=False,
                             error=f'{action}失败: {e}', status_code=500)

    api_action = 'reboot' if restart else 'shutdown'
    if result.returncode == 0:
        return _api_response(api_action, action, success=True,
                             message=f'{action}指令已发送', status_code=202)

    err = (result.stderr or result.stdout or '').strip()
    low = err.lower()
    if 'permission denied' in low or 'password' in low:
        hint = 'SSH 认证失败:用户名或密码错误'
    elif 'connection refused' in low:
        hint = '连接被拒绝:目标电脑的 SSH 服务未启动或被防火墙拦截'
    elif 'timed out' in low or 'timeout' in low:
        hint = f'连接超时:目标电脑可能刚关机,或 IP {ip} 不可达'
    elif 'no route' in low or 'unreachable' in low:
        hint = f'网络不可达:IP {ip} 无法到达'
    elif 'host key verification failed' in low:
        hint = '主机密钥验证失败'
    elif 'could not resolve' in low:
        hint = '无法解析主机名'
    else:
        hint = err or f'未知错误(退出码 {result.returncode})'
    return _api_response(api_action, action, success=False,
                         error=f'{action}失败:{hint}', status_code=502)


# API 专供网页:使用 POST,返回 JSON;快捷指令继续使用下面的旧地址和 GET(纯文本)。
@app.post('/api/wake')
def api_wake():
    payload, code = _wake_action()
    return _json(payload, code)


@app.post('/api/shutdown')
def api_shutdown():
    payload, code = _ssh_shutdown_action(restart=False)
    return _json(payload, code)


@app.post('/api/reboot')
def api_reboot():
    payload, code = _ssh_shutdown_action(restart=True)
    return _json(payload, code)


# 保留旧地址,兼容已有快捷指令;旧接口使用 GET,返回纯文本。
@app.get('/wake')
def wake_legacy():
    payload, code = _wake_action()
    return _text(payload, code)


@app.get('/shutdown')
def shutdown_legacy():
    payload, code = _ssh_shutdown_action(restart=False)
    return _text(payload, code)


@app.get('/reboot')
def reboot_legacy():
    payload, code = _ssh_shutdown_action(restart=True)
    return _text(payload, code)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000, debug=False)
# 代码里含密码,收紧权限
chmod 600 ~/code/pc-control/app.py

5.4 systemd 用户服务(开机自启 + 崩溃自动拉起)

保存为 ~/.config/systemd/user/pc-control.service(目录不存在先 mkdir -p):

[Unit]
Description=PC Control (Remote Wake/Shutdown)
After=network-online.target
Wants=network-online.target
StartLimitBurst=5
StartLimitIntervalSec=60

[Service]
ExecStart=/home/<你的用户名>/code/pc-control/.venv/bin/waitress-serve --host=0.0.0.0 --port=5000 app:app
WorkingDirectory=/home/<你的用户名>/code/pc-control
Restart=always
RestartSec=5
RestartPreventExitStatus=78
TimeoutStopSec=30
TimeoutStartSec=30
SuccessExitStatus=0 143
KillMode=control-group

[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user enable --now pc-control
# 关键一步:允许用户服务在"没人登录"时也运行(无桌面服务器必须)
sudo loginctl enable-linger $USER

用 waitress 而不是 Flask 自带服务器:后者是开发用的,并发和稳定性都不行;waitress 是纯 Python 的生产级 WSGI 服务器。

5.5 验证

systemctl --user status pc-control           # 应为 active (running)
curl http://127.0.0.1:5000/wake              # 返回:开机指令已发送
curl http://$(hostname).local:5000/wake      # 同样返回 → mDNS 链路也通了

六、手机端:iOS 快捷指令

以"开机"为例:

  1. 快捷指令 → + 新建
  2. 添加操作 "获取 URL 内容":URL 填 http://<服务器主机名>.local:5000/wake(方法默认 GET)
  3. 添加操作 "显示通知"(或"显示结果")
  4. 命名为"开机电脑",可添加到桌面 / 锁屏 / 轻点背面

关机、重启同理,路径换成 /shutdown、/reboot。网页党直接收藏 http://<服务器主机名>.local:5000/。

主机名就是服务器的 hostname,本机实测为 teohzy-debian-oldpc.local;换机器时 .local 前缀跟着变。

七、验收清单

  • 手机浏览器打开 .local:5000 能看到三个按钮
  • 点"关机"→ PC 真的关了,页面/通知显示"关机指令已发送"
  • 点"开机"→ PC 真的开机了
  • 免固定 IP 验证:让 PC 换个 IP(重启路由器或路由器里改 DHCP),不做任何配置改动,再点一次"关机"仍成功
  • 重启服务器后服务自动在线(linger 生效)

八、常见问题排查

症状 原因与处理
开机指令发了但 PC 没反应 ① BIOS WOL/ErP 没开 ② 网卡电源管理没勾 ③ 配置里 MAC 填成了 Wi-Fi 网卡的 ④ 快速启动干扰,关掉试试 ⑤ 发包机器和 PC 不在同一网段(广播不跨路由)
关机报"未找到 PC" PC 已关机(正常现象);或 PC 跨了 NAT 子路由与本服务不同网段(本方案边界,见第十节)
关机报 SSH 超时/拒绝 PC 没开机、sshd 没装/没启动、Windows 防火墙拦 22
SSH 认证失败 用户名密码错;微软账户登录的机器注意用本地账号
.local 打不开 手机不在同一 Wi-Fi;路由器开了"AP 隔离/客户端隔离";部分 Android 对 .local 支持差(iOS 无碍)
服务器重启后服务没起来 没做 loginctl enable-linger
改了 app.py 不生效 systemctl --user restart pc-control
网页背景粒子动画不显示 particles.js 走公共 CDN,离线时只是没动画,功能不受影响

九、日常维护速查

systemctl --user status pc-control     # 状态
systemctl --user restart pc-control    # 改完代码/配置后重启
journalctl --user -u pc-control -f     # 实时日志
  • 改配置:只动 app.py 顶部配置区(MAC / 账号 / 密码;网段和广播地址留空 = 自动探测)
  • last_ip.json:IP 解析缓存,自动维护,删了也没事(下次多花约 5 秒全段扫描)
  • 换路由器 / 换网段 / IP 全变:什么都不用做

十、已知边界与扩展方向

  1. 同网段限制:ARP 反查和 WOL 广播都要求服务器与 PC 在同一二层。如果以后 PC 被挪到某台 NAT 子路由下面,这套方案会失效——届时关机/重启改走 Tailscale(100.x 固定 IP)或反向 SSH 隧道,开机仍需在 PC 上一跳路由器上发魔术包
  2. 无鉴权:服务本身没有登录验证,仅限家庭内网使用;不要用端口转发 / frp 暴露到公网,非要暴露先加 token
  3. PC 必须有线:Wi-Fi 网卡基本不支持关机唤醒
  4. 可扩展:加状态检测接口(探测 22 端口显示在线/离线)、加 token、接入 Home Assistant

相关文件:服务器 ~/code/pc-control/(app.py、last_ip.json)+ ~/.config/systemd/user/pc-control.service。本文对应 2026-10-03 部署实测版本(含"按 MAC 免固定 IP"与 mDNS 入口改造)。