节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
11.80 KiB
Stardust
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.Extensions.Options;
using NewLife;
using NewLife.Log;
using NewLife.Remoting;
using NewLife.Remoting.Extensions;
using NewLife.Remoting.Models;
using NewLife.Remoting.Services;
using NewLife.Security;
using NewLife.Web;
using Stardust.Data.Nodes;
using Stardust.Models;
using Stardust.Server.Services;
using XCode.Membership;
using WebSocket = System.Net.WebSockets.WebSocket;

namespace Stardust.Server.Controllers;

[ApiController]
[Route("[controller]")]
public class NodeController(NodeService nodeService, ITokenService tokenService, NodeSessionManager sessionManager, IServiceProvider serviceProvider, ITracer tracer, DownloadTokenService downloadTokenService) : BaseController(nodeService, tokenService, serviceProvider)
{
    private static DateTime _oldVersion = new(2025, 8, 26);

    #region 令牌验证
    protected override Boolean OnAuthorize(String token, DeviceContext context)
    {
        ManageProvider.UserHost = UserHost;

        Exception error = null;
        try
        {
            if (!base.OnAuthorize(token, context)) return false;
        }
        catch (Exception ex)
        {
            error = ex;
        }

        var node = Node.FindByCode(Jwt?.Subject);
        if (node == null || !node.Enable) error ??= new ApiException(ApiCode.Unauthorized, L.T("Invalid node."));

        Context.Device = node;

        // 旧版本节点允许匿名访问心跳和更新接口
        var actionContext = context["__ActionContext"] as ActionContext;
        var action = (actionContext.ActionDescriptor as ControllerActionDescriptor)?.ActionName;
        if (error is ApiException aex && aex.Code == ApiCode.Unauthorized &&
            node != null && node.CompileTime < _oldVersion &&
            action.EqualIgnoreCase(nameof(Ping), nameof(Upgrade)))
        {
            error = null;
        }

        if (error != null) throw error;

        return node != null;
    }
    #endregion

    #region 登录注销
    /// <summary>节点注册与登录。兼容旧版本请求体,动态注册后颁发令牌与证书</summary>
    /// <param name="data">登录信息(JSON)</param>
    /// <returns>登录响应</returns>
    [AllowAnonymous]
    [HttpPost(nameof(Login))]
    public ILoginResponse Login(JsonElement data)
    {
        var span = DefaultSpan.Current;

        // 由于客户端的多样性,这里需要手工控制序列化。某些客户端的节点信息跟密钥信息在同一层级。
        var jsonOptions = HttpContext.RequestServices.GetService<IOptions<JsonOptions>>();
        var options = jsonOptions.Value.JsonSerializerOptions;
        var inf = data.Deserialize<LoginInfo>(options);
        if (inf.Node == null || inf.Node.UUID.IsNullOrEmpty() && inf.Node.MachineGuid.IsNullOrEmpty() && inf.Node.Macs.IsNullOrEmpty())
        {
            span?.AppendTag("Compatible with legacy request body");
            inf.Node = data.Deserialize<NodeInfo>(options);
        }

        var ip = UserHost;
        var code = inf.Code;
        var node = Node.FindByCode(code, true);
        var oldSecret = node?.Secret;
        Context.Device = node;

        if (node != null && !node.Enable) throw new ApiException(ApiCode.Unauthorized, L.T("Login is forbidden."));

        // 支持自动识别2020年的XCoder版本,兼容性处理
        if (inf.ProductCode.IsNullOrEmpty())
        {
            var installPath = inf.Node?.InstallPath;
            span?.AppendTag(L.TLog("Compatible with the legacy XCoder (2020), installPath: {0}", installPath));
            if (!installPath.IsNullOrEmpty())
            {
                if (installPath.Contains("XCoder"))
                    inf.ProductCode = "XCoder";
                else if (installPath.Contains("CrazyCoder"))
                    inf.ProductCode = "CrazyCoder";
            }
        }

        //// 设备不存在或者验证失败,执行注册流程
        //if (node != null && !nodeService.Authorize(node, inf.Secret, inf, ip))
        //{
        //    node = null;
        //}

        //node ??= nodeService.Register(inf, ip);
        //Context.Device = node;

        //if (node == null) throw new ApiException(ApiCode.Unauthorized, L.T("Node authentication failed."));

        //var tokenModel = nodeService.Login(node, inf, ip);

        //var rs = new LoginResponse
        //{
        //    Name = node.Name,
        //    Token = tokenModel.AccessToken,
        //};

        var request = inf;
        var rs = nodeService.Login(Context, request, "Http");
        node = Context.Device as Node ?? throw new ApiException(ApiCode.Unauthorized, L.T("Node authentication failed."));

        //rs.Time = inf.Node.Time;
        rs.ServerTime = DateTime.UtcNow.ToLong();

        // 动态注册的设备不可用时,不要发令牌,只发证书
        if (node.Enable)
        {
            if (request.ClientId.IsNullOrEmpty()) Context.ClientId = request.ClientId = Rand.NextString(8);
            var tm = tokenService.IssueToken(node.Code, request.ClientId);

            rs.Token = tm.AccessToken;
            rs.Expire = tm.ExpireIn;
        }

        // 动态注册,下发节点证书
        if (node.Code != code || node.Secret != oldSecret)
        {
            rs.Code = node.Code;
            rs.Secret = node.Secret;
        }

        return rs;
    }

    /// <summary>注销</summary>
    /// <param name="reason">注销原因</param>
    /// <returns></returns>
    [HttpGet(nameof(Logout))]
    [HttpPost(nameof(Logout))]
    public LoginResponse Logout(String reason)
    {
        nodeService.Logout(Context, reason, "Http");

        return new LoginResponse
        {
            Name = Context.Device?.Name,
            Token = null,
        };
    }
    #endregion

    #region 心跳保活
    /// <summary>节点心跳保活。更新在线状态与令牌续期</summary>
    /// <param name="inf">心跳信息</param>
    /// <returns>心跳响应</returns>
    [HttpGet(nameof(Ping))]
    [HttpPost(nameof(Ping))]
    public IPingResponse Ping(PingInfo inf)
    {
        var rs = nodeService.Ping(Context, inf, new MyPingResponse());

        if (Context.Device is Node node)
        {
            rs.Period = node.Period;

            if (rs is IPingResponse2 rs2)
                rs2.NewServer = !node.NewServer.IsNullOrEmpty() ? node.NewServer : node.Project?.NewServer;

            if (rs is MyPingResponse mrs)
            {
                // 服务端设置节点的同步时间周期时,客户端会覆盖掉;服务端未设置时,不要覆盖客户端的同步参数
                if (node.SyncTime > 0) mrs.SyncTime = node.SyncTime;
            }

            // 令牌有效期检查,10分钟内到期的令牌,颁发新令牌,以获取业务的连续性。
            var (jwt, ex) = tokenService.DecodeToken(Token);
            if (ex == null && jwt != null && jwt.Expire < DateTime.Now.AddMinutes(10))
            {
                using var span = tracer?.NewSpan("RefreshNodeToken", new { node.Code, node.Name });

                var tm = tokenService.IssueToken(node.Code, Context.ClientId);
                rs.Token = tm.AccessToken;
            }

            //if (!node.Version.IsNullOrEmpty() && Version.TryParse(node.Version, out var ver))
            //{
            //    // 拉取命令
            //    if (ver.Build >= 2023 && ver.Revision >= 107)
            //        rs.Commands = nodeService.AcquireCommands(node.ID);
            //}
        }

        return rs;
    }

    //[AllowAnonymous]
    //[HttpGet(nameof(Ping))]
    //public PingResponse Ping() => new() { Time = 0, ServerTime = DateTime.UtcNow.ToLong(), };
    #endregion

    #region 升级更新
    /// <summary>升级检查</summary>
    /// <param name="channel">更新通道。兼容旧版本客户端,允许为空,默认Release</param>
    /// <returns></returns>
    [HttpGet(nameof(Upgrade))]
    [HttpPost(nameof(Upgrade))]
    public IUpgradeInfo? Upgrade(String? channel)
    {
        var node = Context.Device as Node;

        // 基础路径
        var uri = Request.GetRawUrl().ToString();
        var p = uri.IndexOf('/', "https://".Length);
        if (p > 0) uri = uri[..p];

        var info = nodeService.Upgrade(Context, channel);
        if (info == null)
        {
            nodeService.CheckDotNet(node, new Uri(uri), UserHost);

            return null;
        }

        // 为了兼容旧版本客户端,这里必须把路径处理为绝对路径
        if (info != null && !info.Source.StartsWithIgnoreCase("http://", "https://"))
        {
            info.Source = new Uri(new Uri(uri), info.Source) + "";
        }

        // 安全V-11:升级包下载URL附带短时令牌
        if (info != null && !info.Source.IsNullOrEmpty())
            info.Source = downloadTokenService.AppendToken(info.Source, StarServerSetting.Current.AttachmentTokenExpire);

        return info!;
    }
    #endregion

    #region 事件上报
    /// <summary>批量上报事件</summary>
    /// <param name="events">事件集合</param>
    /// <returns></returns>
    [ApiFilter]
    [HttpPost(nameof(PostEvents))]
    public Int32 PostEvents(EventModel[] events) => nodeService.PostEvents(Context, events);
    #endregion

    #region 下行通知
    /// <summary>下行通知。通知节点更新、安装和启停应用等</summary>
    /// <returns></returns>
    [HttpGet("/node/notify")]
    public async Task Notify()
    {
        if (HttpContext.WebSockets.IsWebSocketRequest)
        {
            using var socket = await HttpContext.WebSockets.AcceptWebSocketAsync();

            await HandleNotify(Context, socket, HttpContext.RequestAborted);
        }
        else
        {
            HttpContext.Response.StatusCode = 400;
        }
    }

    private async Task HandleNotify(DeviceContext context, WebSocket socket, CancellationToken cancellationToken)
    {
        var node = context.Device as Node;

        // BaseController.OnAuthorize 中已通过 deviceService.GetOnline 设置 Context.Online,
        // WebSocket 升级请求也走 OnAuthorize,后续心跳消息可直接复用。

        using var span = tracer?.NewSpan("cmd:WsNode:Create", node.Code);
        span?.Detach(HttpContext.Request.Headers);
        try
        {
            using var session = new NodeCommandSession(socket)
            {
                Code = node.Code,
                Log = this,
                SetOnline = online => nodeService.SetOnline(Context, online),
                ServiceProvider = serviceProvider,
                Tracer = tracer,
            };
            sessionManager.Add(session);

            context["HttpContext"] = HttpContext;
            context["Span"] = span;

            await session.WaitAsync(context, cancellationToken);
        }
        catch (Exception ex)
        {
            // 这里不一定命中,因 WaitAsync 内部会提前结束span
            span?.SetError(ex, null);
            throw;
        }
    }

    /// <summary>向节点发送命令。通知节点更新、安装和启停应用等</summary>
    /// <param name="model"></param>
    /// <param name="token">应用令牌</param>
    /// <returns></returns>
    [AllowAnonymous]
    [HttpPost(nameof(SendCommand))]
    public Task<CommandReplyModel> SendCommand(CommandInModel model)
    {
        if (model.Code.IsNullOrEmpty()) throw new ArgumentNullException(nameof(model.Code), L.T("Node must be specified."));
        if (model.Command.IsNullOrEmpty()) throw new ArgumentNullException(nameof(model.Command));

        return nodeService.SendCommand(Context, model);
    }

    /// <summary>设备端响应服务调用</summary>
    /// <param name="model">服务</param>
    /// <returns></returns>
    [HttpPost(nameof(CommandReply))]
    public Int32 CommandReply(CommandReplyModel model) => nodeService.CommandReply(Context, model);
    #endregion
}