节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
21.24 KiB
Stardust
using System.Diagnostics;
using System.Runtime.InteropServices;
using NewLife;
using NewLife.Agent;
using NewLife.Log;
using NewLife.Model;
using NewLife.Remoting.Clients;
using NewLife.Remoting.Models;
using NewLife.Serialization;
using Stardust;
using Stardust.Models;

namespace StarAgent;

/// <summary>实例化</summary>
/// <param name="set"></param>
internal class MyStarClient(StarAgentSetting set) : StarClient(set)
{
    #region 属性
    //public IHost Host { get; set; }

    public ServiceBase Service { get; set; } = null!;

    public StarAgentSetting AgentSetting { get; set; } = set;

    ///// <summary>项目名。新节点默认所需要加入的项目</summary>
    //public String Project { get; set; }

    private Boolean InService
    {
        get
        {
            var inService = "-s".EqualIgnoreCase(Environment.GetCommandLineArgs());
            if (inService) return true;

            // 以服务方式运行时,重启服务,否则采取拉起进程的方式
            if (Service != null && Service.Host is DefaultHost host && host.InService) return true;

            return false;
        }
    }
    #endregion

    #region 方法
    protected override void OnInit()
    {
        var provider = ServiceProvider ??= ObjectContainer.Provider;

        // 找到容器,注册默认的模型实现,供后续InvokeAsync时自动创建正确的模型对象
        var container = ModelExtension.GetService<IObjectContainer>(provider) ?? ObjectContainer.Current;
        if (container != null)
        {
            container.AddTransient<IPingResponse, MyPingResponse>();
        }

        base.OnInit();
    }
    #endregion

    #region 登录
    public override void Open()
    {
        this.RegisterCommand("node/restart", Restart);
        this.RegisterCommand("node/reboot", Reboot);
        this.RegisterCommand("node/setchannel", SetChannel);
        this.RegisterCommand("node/synctime", SyncTime);
        this.RegisterCommand("bash", RunBash);
        this.RegisterCommand("cmd", RunCmd);

        base.Open();
    }

    public override ILoginRequest BuildLoginRequest()
    {
        var set = AgentSetting;
        var request = base.BuildLoginRequest();
        if (request is LoginInfo req)
        {
            req.Project = set.Project;

            var info = req.Node;
            if (info != null && InService)
            {
                if (!set.Dpi.IsNullOrEmpty()) info.Dpi = set.Dpi;
                if (!set.Resolution.IsNullOrEmpty()) info.Resolution = set.Resolution;
            }
        }

        return request;
    }
    #endregion

    #region 心跳
    private DateTime _lastSync;
    protected override async Task OnPing(Object state)
    {
        await base.OnPing(state);

        // 上报设备侧自愈记录(救援脚本恢复代理后留下的标记;见 SmartIPC 固件 staragent-rescue 机制)
        CheckRescueFlag();

        var syncTime = AgentSetting.SyncTime;
        if (syncTime > 0 && Span.TotalMilliseconds != 0)
        {
            var now = DateTime.Now;
            if (_lastSync.AddSeconds(syncTime) < now)
            {
                _lastSync = now;

                try
                {
                    // 同步时间
                    SyncTime();
                }
                catch (Exception ex)
                {
                    WriteLog("Time synchronization failed: {0}", ex.Message);
                }
            }
        }
    }

    public override async Task<IPingResponse?> Ping(CancellationToken cancellationToken = default)
    {
        var rs = await base.Ping(cancellationToken);
        if (rs is MyPingResponse mpr)
        {
            var set = AgentSetting;
            var syncTime = mpr.SyncTime;
            if (syncTime > 0 && syncTime != set.SyncTime)
            {
                WriteLog("Time synchronization interval changed to {0} second(s)", syncTime);

                set.SyncTime = syncTime;
                set.Save();
            }
        }

        return rs;
    }

    /// <summary>上报设备侧自愈记录</summary>
    /// <remarks>
    /// SmartIPC 固件的巡检服务(newlife-starcare.timer)发现代理异常时,会用内置救援副本恢复代理,
    /// 并写入 /var/lib/newlife/starrescue.flag 记录救援原因与结果;代理恢复上线后读取该标记,
    /// 作为节点事件上报到星尘平台,随后删除标记(只上报一次)。
    /// </remarks>
    private void CheckRescueFlag()
    {
        // 仅 Linux 设备(救援机制随固件部署),Windows 服务跳过
        if (!Runtime.Linux) return;

        try
        {
            var flag = "/var/lib/newlife/starrescue.flag";
            if (!File.Exists(flag)) return;

            var content = File.ReadAllText(flag);

            // 先上报再删除:入队失败时保留标记,下次心跳重试
            this.WriteInfoEvent("Rescue", L.T("Stardust agent self-healing completed: {0}", content));
            WriteLog("Reported device-side self-healing record: {0}", content);

            File.Delete(flag);
        }
        catch (Exception ex)
        {
            // 上报失败不影响心跳主流程
            XTrace.WriteException(ex);
        }
    }
    #endregion

    #region 更新
    public override Task<IUpgradeInfo?> Upgrade(String? channel, CancellationToken cancellationToken = default)
    {
        if (channel.IsNullOrEmpty()) channel = AgentSetting.Channel;

        return base.Upgrade(channel, cancellationToken);
    }

    protected override void Restart(Upgrade upgrade)
    {
        // 带有-s参数就算是服务中运行
        var inService = "-s".EqualIgnoreCase(Environment.GetCommandLineArgs());
        var pid = Process.GetCurrentProcess().Id;

        // 使用当前进程可执行文件名,支持重命名场景(如 MyAgent.exe)
        var exeName = GetExecutableName();

        // 诊断埋点:记录 exeName 和进程信息,排查更新后重启失败问题
        var p = Process.GetCurrentProcess();
        this.WriteInfoEvent("Upgrade", $"Restart: exeName={exeName} ProcessName={p.ProcessName} MainModule={p.MainModule?.FileName}");

        // 以服务方式运行时,重启服务,否则采取拉起进程的方式
        if (inService || Service.Host is DefaultHost host && host.InService)
        {
            this.WriteInfoEvent("Upgrade", L.T("Forced update completed; restarting the background service. PID={0}", pid));

            // 冒烟测试:尝试拉起新版,若失败等 5s 重试一次(应对慢速存储落盘 / JIT 预热)
            var rs = upgrade.Run(exeName, "-restart -upgrade", 3_000);
            if (!rs)
            {
                var delay = 5_000;
                this.WriteInfoEvent("Upgrade", L.T("The new version failed to start for the first time; retrying in {0}ms. {1}", delay, upgrade.LastErrorMessage));
                Thread.Sleep(delay);
                rs = upgrade.Run(exeName, "-restart -upgrade", 3_000);
            }

            //!! 这里不需要自杀,外部命令重启服务会结束当前进程
            if (rs)
            {
                this.WriteInfoEvent("Upgrade", L.T("Forced update completed; the new process has been launched, waiting for the current service to restart."));
            }
            else
            {
                // 两次均失败,确认新版有致命缺陷,回滚 .del 恢复旧版文件
                upgrade.Rollback();
                this.WriteInfoEvent("Upgrade", L.T("The new version failed to start (smoke test failed); the old files have been rolled back and the current service keeps running. {0}", upgrade.LastErrorMessage));
            }
        }
        else
        {
            this.WriteInfoEvent("Upgrade", L.T("Forced update completed; launching the new process. PID={0}", pid));

            // 冒烟测试:尝试拉起新版,若失败等 5s 重试一次
            var rs = upgrade.Run(exeName, "-run -upgrade", 3_000);
            if (!rs)
            {
                var delay = 5_000;
                this.WriteInfoEvent("Upgrade", L.T("The new version failed to start for the first time; retrying in {0}ms. {1}", delay, upgrade.LastErrorMessage));
                Thread.Sleep(delay);
                rs = upgrade.Run(exeName, "-run -upgrade", 3_000);
            }

            if (rs)
            {
                Service.StopWork("Upgrade");

                this.WriteInfoEvent("Upgrade", L.T("Forced update completed; the new process has been launched and the current process is about to exit. PID={0}", pid));

                upgrade.KillSelf();
            }
            else
            {
                // 两次均失败,确认新版有致命缺陷,回滚 .del 恢复旧版文件
                upgrade.Rollback();
                this.WriteInfoEvent("Upgrade", L.T("The new version failed to start (smoke test failed); the old files have been rolled back and the current service keeps running. {0}", upgrade.LastErrorMessage));
            }
        }
    }

    /// <summary>获取当前进程可执行文件名(不含扩展名),支持重命名场景。dotnet/mono 宿主下返回真正目标程序集名</summary>
    private static String GetExecutableName()
    {
        // 获取进程名(不含扩展名),再防御性清理可能混入的后缀
        var name = Process.GetCurrentProcess().GetProcessName() ?? "StarAgent";
        return name.TrimSuffix(".exe", ".dll", ".del");
    }

    /// <summary>使用当前进程可执行文件完整路径启动新进程,避免依赖工作目录中的文件名</summary>
    private static Boolean RunCurrentProcess(String args)
    {
        try
        {
            var process = Process.GetCurrentProcess();
            var exePath = process.MainModule?.FileName;
            if (exePath.IsNullOrEmpty()) return false;

            var arguments = args;

            // 兼容 dotnet/mono 启动模式(Linux 常见:dotnet StarAgent.dll)
            // 参考 ProcessHelper.GetProcessName 的检测逻辑
            var pname = process.ProcessName;
            if (pname.EqualIgnoreCase("dotnet", "dotnet.exe") || pname?.EndsWith("/dotnet") == true)
            {
                var dll = Environment.GetCommandLineArgs()[0].GetFullPath();
                if (dll.Contains(' ')) dll = $"\"{dll}\"";
                arguments = $"{dll} {args}";
            }
            else if (pname.EqualIgnoreCase("mono", "mono.exe", "mono-sgen"))
            {
                exePath = Environment.GetCommandLineArgs()[0].GetFullPath();
            }

            var si = new ProcessStartInfo
            {
                FileName = exePath,
                Arguments = arguments,
                UseShellExecute = false,
            };

            return Process.Start(si) != null;
        }
        catch (Exception ex)
        {
            XTrace.WriteException(ex);
            return false;
        }
    }
    #endregion

    #region 扩展功能
    /// <summary>重启应用服务</summary>
    private String? Restart(String? argument)
    {
        // 异步执行,让方法调用返回结果给服务端
        Task.Factory.StartNew(() =>
        {
            Thread.Sleep(1000);

            var upgrade = new Upgrade { Log = XTrace.Log };

            // 带有-s参数就算是服务中运行
            var inService = "-s".EqualIgnoreCase(Environment.GetCommandLineArgs());

            // 以服务方式运行时,重启服务,否则采取拉起进程的方式
            if (inService || Service.Host is DefaultHost host && host.InService)
            {
                // 使用当前进程可执行文件路径重启服务,避免硬编码进程名导致重命名后失效
                var rs = RunCurrentProcess("-restart -delay");

                //!! 这里不需要自杀,外部命令重启服务会结束当前进程
                return rs + "";
            }
            else
            {
                // 使用当前进程可执行文件路径重新拉起进程
                var rs = RunCurrentProcess("-run -delay");
                if (rs)
                {
                    Service.StopWork("Upgrade");

                    upgrade.KillSelf();
                }

                return rs + "";
            }
        }, TaskCreationOptions.LongRunning);

        return "success";
    }

    /// <summary>重启操作系统</summary>
    private String? Reboot(String? argument)
    {
        var dic = argument.IsNullOrEmpty() ? null : JsonParser.Decode(argument);
        var timeout = dic?["timeout"].ToInt();

        // 异步执行,让方法调用返回结果给服务端
        Task.Factory.StartNew(() =>
        {
            Thread.Sleep(1000);

            if (Runtime.Windows)
            {
                if (timeout > 0)
                    "shutdown".ShellExecute($"-r -t {timeout}");
                else
                    "shutdown".ShellExecute($"-r");

                Thread.Sleep(5000);
                "shutdown".ShellExecute($"-r -f");
            }
            else if (Runtime.Linux)
            {
                // 重启Linux之前先同步数据到硬盘
                "sync".ShellExecute();

                // 多种方式重启Linux,先使用温和的方式
                "systemctl".ShellExecute("reboot");

                Thread.Sleep(5000);
                "shutdown".ShellExecute("-r now");

                Thread.Sleep(5000);
                "reboot".ShellExecute();
            }
            else if (Runtime.OSX)
            {
                // 先同步数据到硬盘,再以非交互方式重启,避免等待 sudo 密码
                "sync".ShellExecute();
                "sudo".ShellExecute("-n shutdown -r now");
            }
        }, TaskCreationOptions.LongRunning);

        return "success";
    }

    /// <summary>设置通道</summary>
    /// <param name="argument"></param>
    /// <returns></returns>
    private String? SetChannel(String? argument)
    {
        if (argument.IsNullOrEmpty()) return L.T("Parameter is empty.");

        var set = AgentSetting;
        set.Channel = argument;
        set.Save();

        return "success " + argument;
    }

    /// <summary>同步时间</summary>
    public String? SyncTime(String? argument = null)
    {
        var now = DateTime.Now;
        var time = GetNow();

        // 指令传达的参数可能指定了时间
        if (!argument.IsNullOrEmpty())
        {
            var dt = argument.ToDateTime();
            if (dt.Year > 2000 && dt.Year < 3000) time = dt;
        }

        var ts = now - time;
        if (Math.Abs(ts.TotalMilliseconds) < 1000) return L.T("No synchronization required.");

        var msg = L.T("Synchronized time: {0}, offset: {1}", time.ToFullString(), ts);
        WriteLog(msg);
        WriteEvent("info", "SyncTime", msg);
        time = time.ToUniversalTime();

        var rs = L.T("Unsupported system!");
        if (Runtime.Windows)
        {
            var st = new SYSTEMTIME
            {
                Year = (Int16)time.Year,
                Month = (Int16)time.Month,
                Day = (Int16)time.Day,
                Hour = (Int16)time.Hour,
                Minute = (Int16)time.Minute,
                Second = (Int16)time.Second,
                Milliseconds = (Int16)time.Millisecond
            };

            if (!SetSystemTime(ref st))
                throw new InvalidOperationException("Unable to set system time.");

            rs = L.T("Succeeded");
        }
        else if (Runtime.Linux)
        {
            rs = "date".Execute($"-u -s \"{time:yyyy-MM-dd HH:mm:ss}\"", 5_000);

            // 时间偏差较大时,需要写入RTC时钟,否则时间会被硬件时钟覆盖
            if (Math.Abs(ts.TotalSeconds) > 60)
            {
                rs += ", " + "hwclock".Execute("-u -w", 5_000);
            }
        }
        else if (Runtime.OSX)
        {
            // macOS 修改系统时间需要 root 权限,-n 禁止交互式密码输入,避免命令挂起
            rs = "sudo".Execute($"-n date -u -s \"{time:yyyy-MM-dd HH:mm:ss}\"", 5_000);
            if (rs.IsNullOrEmpty()) rs = L.T("Synchronization failed; root permission is required. Run manually: sudo date -u -s \"{0}\"", time.ToString("yyyy-MM-dd HH:mm:ss"));
        }

        WriteLog(rs);

        return rs;
    }

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern Boolean SetSystemTime(ref SYSTEMTIME st);

    [StructLayout(LayoutKind.Sequential)]
    private struct SYSTEMTIME
    {
        public Int16 Year;
        public Int16 Month;
        public Int16 DayOfWeek;
        public Int16 Day;
        public Int16 Hour;
        public Int16 Minute;
        public Int16 Second;
        public Int16 Milliseconds;
    }

    /// <summary>执行Bash命令</summary>
    /// <param name="argument">命令参数。可以是纯字符串命令,或JSON格式{"cmd":"命令","timeout":超时毫秒}</param>
    /// <returns></returns>
    public String? RunBash(String? argument)
    {
        if (argument.IsNullOrEmpty()) return L.T("Parameter is empty.");

        var cmd = argument;
        var timeout = 30_000;

        // 尝试解析JSON格式参数
        if (argument.StartsWith("{"))
        {
            try
            {
                var dic = JsonParser.Decode(argument);
                if (dic != null)
                {
                    cmd = dic["cmd"] + "";
                    if (dic.ContainsKey("timeout")) timeout = dic["timeout"].ToInt();
                }
            }
            catch
            {
                // 解析失败则当作普通命令处理
            }
        }

        if (cmd.IsNullOrEmpty()) return L.T("Command is empty.");

        // 审计日志:记录命令执行前的信息
        var now = DateTime.Now;
        WriteLog("Executing Bash command: {0}, timeout: {1}ms", cmd, timeout);
        WriteEvent("warn", "RunBash", L.T("Executing command: {0}, timeout: {1}ms, time: {2}", cmd, timeout, now.ToString("yyyy-MM-dd HH:mm:ss")));

        var sw = Stopwatch.StartNew();
        try
        {
            var rs = "bash".Execute($"-c \"{cmd.Replace("\"", "\\\"")}\"", timeout);
            sw.Stop();

            // 审计日志:记录成功执行的详细信息(输出过长时截断)
            var resultPreview = rs?.Length > 1000 ? rs.Substring(0, 1000) + "..." : rs;
            WriteLog("Execution succeeded, elapsed: {0}ms, output length: {1}", sw.ElapsedMilliseconds, rs?.Length ?? 0);
            WriteEvent("info", "RunBash", L.T("Execution succeeded, command: {0}, elapsed: {1}ms, output length: {2}", cmd, sw.ElapsedMilliseconds, rs?.Length ?? 0));

            return rs;
        }
        catch (Exception ex)
        {
            sw.Stop();

            // 审计日志:记录失败信息
            WriteLog("Execution failed: {0}, elapsed: {1}ms", ex.Message, sw.ElapsedMilliseconds);
            WriteEvent("error", "RunBash", L.T("Execution failed, command: {0}, elapsed: {1}ms, error: {2}", cmd, sw.ElapsedMilliseconds, ex.Message));

            return L.T("Command execution failed: {0}", ex.Message);
        }
    }

    /// <summary>执行CMD命令</summary>
    /// <param name="argument">命令参数。可以是纯字符串命令,或JSON格式{"cmd":"命令","timeout":超时毫秒}</param>
    /// <returns></returns>
    public String? RunCmd(String? argument)
    {
        if (argument.IsNullOrEmpty()) return L.T("Parameter is empty.");

        var cmd = argument;
        var timeout = 30_000;

        // 尝试解析JSON格式参数
        if (argument.StartsWith("{"))
        {
            try
            {
                var dic = JsonParser.Decode(argument);
                if (dic != null)
                {
                    cmd = dic["cmd"] + "";
                    if (dic.ContainsKey("timeout")) timeout = dic["timeout"].ToInt();
                }
            }
            catch
            {
                // 解析失败则当作普通命令处理
            }
        }

        if (cmd.IsNullOrEmpty()) return L.T("Command is empty.");

        // 审计日志:记录命令执行前的信息
        var now = DateTime.Now;
        WriteLog("Executing CMD command: {0}, timeout: {1}ms", cmd, timeout);
        WriteEvent("warn", "RunCmd", L.T("Executing command: {0}, timeout: {1}ms, time: {2}", cmd, timeout, now.ToString("yyyy-MM-dd HH:mm:ss")));

        var sw = Stopwatch.StartNew();
        try
        {
            var rs = "cmd".Execute($"/c {cmd}", timeout);
            sw.Stop();

            // 审计日志:记录成功执行的详细信息(输出过长时截断)
            var resultPreview = rs?.Length > 1000 ? rs.Substring(0, 1000) + "..." : rs;
            WriteLog("Execution succeeded, elapsed: {0}ms, output length: {1}", sw.ElapsedMilliseconds, rs?.Length ?? 0);
            WriteEvent("info", "RunCmd", L.T("Execution succeeded, command: {0}, elapsed: {1}ms, output length: {2}", cmd, sw.ElapsedMilliseconds, rs?.Length ?? 0));

            return rs;
        }
        catch (Exception ex)
        {
            sw.Stop();

            // 审计日志:记录失败信息
            WriteLog("Execution failed: {0}, elapsed: {1}ms", ex.Message, sw.ElapsedMilliseconds);
            WriteEvent("error", "RunCmd", L.T("Execution failed, command: {0}, elapsed: {1}ms, error: {2}", cmd, sw.ElapsedMilliseconds, ex.Message));

            return L.T("Command execution failed: {0}", ex.Message);
        }
    }
    #endregion
}