Stardust/Stardust.Server/Controllers/NodeController.cs

418 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using NewLife;
using NewLife.Caching;
using NewLife.Log;
using NewLife.Remoting;
using NewLife.Remoting.Extensions;
using NewLife.Remoting.Models;
using NewLife.Web;
using Stardust.Data.Deployment;
using Stardust.Data.Nodes;
using Stardust.Models;
using Stardust.Server.Services;
using XCode;
using XCode.Membership;
using TokenService = Stardust.Server.Services.TokenService;
using WebSocket = System.Net.WebSockets.WebSocket;
namespace Stardust.Server.Controllers;
[ApiController]
[Route("[controller]")]
public class NodeController : BaseController
{
private Node _node;
private String _clientId;
private readonly ITracer _tracer;
private readonly IOptions<JsonOptions> _jsonOptions;
private readonly NodeService _nodeService;
private readonly TokenService _tokenService;
private readonly NodeSessionManager _sessionManager;
private readonly StarServerSetting _setting;
public NodeController(NodeService nodeService, TokenService tokenService, NodeSessionManager sessionManager, StarServerSetting setting, IServiceProvider serviceProvider, ITracer tracer, IOptions<JsonOptions> jsonOptions) : base(serviceProvider)
{
_tracer = tracer;
_jsonOptions = jsonOptions;
_nodeService = nodeService;
_tokenService = tokenService;
_sessionManager = sessionManager;
_setting = setting;
}
#region
protected override Boolean OnAuthorize(String token)
{
ManageProvider.UserHost = UserHost;
var (jwt, node, ex) = _nodeService.DecodeToken(token, _setting.TokenSecret);
_node = node;
_clientId = jwt.Id;
if (ex != null) throw ex;
return node != null;
}
/// <summary>写日志</summary>
/// <param name="action"></param>
/// <param name="success"></param>
/// <param name="message"></param>
public override void WriteLog(String action, Boolean success, String message)
{
var hi = NodeHistory.Create(_node, action, success, message, Environment.MachineName, UserHost);
hi.Insert();
}
#endregion
#region
[AllowAnonymous]
[HttpPost(nameof(Login))]
public LoginResponse Login(JsonElement data)
{
// 由于客户端的多样性,这里需要手工控制序列化。某些客户端的节点信息跟密钥信息在同一层级。
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())
{
inf.Node = data.Deserialize<NodeInfo>(options);
}
var ip = UserHost;
var code = inf.Code;
var node = Node.FindByCode(code, true);
var oldSecret = node?.Secret;
_node = node;
if (node != null && !node.Enable) throw new ApiException(ApiCode.Unauthorized, "禁止登录");
// 支持自动识别2020年的XCoder版本兼容性处理
if (inf.ProductCode.IsNullOrEmpty())
{
var installPath = inf.Node?.InstallPath;
if (!installPath.IsNullOrEmpty())
{
if (installPath.Contains("XCoder"))
inf.ProductCode = "XCoder";
else if (installPath.Contains("CrazyCoder"))
inf.ProductCode = "CrazyCoder";
}
}
// 设备不存在或者验证失败,执行注册流程
if (node != null && !_nodeService.Auth(node, inf.Secret, inf, ip, _setting))
{
node = null;
}
node ??= _nodeService.Register(inf, ip, _setting);
if (node == null) throw new ApiException(ApiCode.Unauthorized, "节点鉴权失败");
var tokenModel = _nodeService.Login(node, inf, ip, _setting);
var rs = new LoginResponse
{
Name = node.Name,
Token = tokenModel.AccessToken,
};
// 动态注册,下发节点证书
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)
{
if (_node != null) _nodeService.Logout(_node, reason, UserHost);
return new LoginResponse
{
Name = _node?.Name,
Token = null,
};
}
#endregion
#region
[HttpPost(nameof(Ping))]
public PingResponse Ping(PingInfo inf)
{
var node = _node;
var rs = new MyPingResponse
{
Time = inf.Time,
ServerTime = DateTime.UtcNow.ToLong(),
};
var online = _nodeService.Ping(node, inf, Token, UserHost);
if (node != null)
{
rs.Period = node.Period;
rs.NewServer = !node.NewServer.IsNullOrEmpty() ? node.NewServer : node.Project?.NewServer;
// 服务端设置节点的同步时间周期时,客户端会覆盖掉;服务端未设置时,不要覆盖客户端的同步参数
if (node.SyncTime > 0) rs.SyncTime = node.SyncTime;
// 令牌有效期检查10分钟内到期的令牌颁发新令牌以获取业务的连续性。
//todo 这里将来由客户端提交刷新令牌,才能颁发新的访问令牌。
var set = _setting;
var tm = _tokenService.ValidAndIssueToken(node.Code, Token, set.TokenSecret, set.TokenExpire, _clientId);
if (tm != null)
{
using var span = _tracer?.NewSpan("RefreshNodeToken", new { node.Code, node.Name });
rs.Token = tm.AccessToken;
//node.WriteHistory("刷新令牌", true, tm.ToJson(), ip);
}
if (!node.Version.IsNullOrEmpty() && Version.TryParse(node.Version, out var ver))
{
// 拉取命令
if (ver.Build >= 2023 && ver.Revision >= 107)
rs.Commands = _nodeService.AcquireNodeCommands(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">更新通道</param>
/// <returns></returns>
[HttpGet(nameof(Upgrade))]
public UpgradeInfo Upgrade(String channel)
{
var node = _node ?? throw new ApiException(ApiCode.Unauthorized, "节点未登录");
// 基础路径
var uri = Request.GetRawUrl().ToString();
var p = uri.IndexOf('/', "https://".Length);
if (p > 0) uri = uri[..p];
var pv = _nodeService.Upgrade(node, channel, UserHost);
if (pv == null)
{
_nodeService.CheckDotNet(node, new Uri(uri), UserHost);
return null;
}
var url = pv.Source;
// 为了兼容旧版本客户端,这里必须把路径处理为绝对路径
if (!url.StartsWithIgnoreCase("http://", "https://"))
{
url = new Uri(new Uri(uri), url) + "";
}
return new UpgradeInfo
{
Version = pv.Version,
Source = url,
FileHash = pv.FileHash,
Preinstall = pv.Preinstall,
Executor = pv.Executor,
Force = pv.Force,
Description = pv.Description,
};
}
#endregion
#region
/// <summary>批量上报事件</summary>
/// <param name="events">事件集合</param>
/// <returns></returns>
[ApiFilter]
[HttpPost(nameof(PostEvents))]
public Int32 PostEvents(EventModel[] events)
{
var ip = UserHost;
var his = new List<NodeHistory>();
var dis = new List<AppDeployHistory>();
foreach (var model in events)
{
var success = !model.Type.EqualIgnoreCase("error");
if (model.Name.EqualIgnoreCase("ServiceController"))
{
var appId = 0;
var p = model.Type.LastIndexOf('-');
if (p > 0)
{
success = !model.Type[(p + 1)..].EqualIgnoreCase("error");
appId = AppDeploy.FindByName(model.Type[..p])?.Id ?? 0;
}
//_deployService.WriteHistory(appId, _node?.ID ?? 0, model.Name, success, model.Remark, UserHost);
var dhi = AppDeployHistory.Create(appId, _node?.ID ?? 0, model.Name, success, model.Remark, ip);
dis.Add(dhi);
}
//WriteHistory(null, model.Name, success, model.Time.ToDateTime().ToLocalTime(), model.Remark);
var hi = NodeHistory.Create(_node, model.Name, success, model.Remark, Environment.MachineName, ip);
var time = model.Time.ToDateTime().ToLocalTime();
if (time.Year > 2000) hi.CreateTime = time;
his.Add(hi);
}
his.Insert();
dis.Insert();
return events.Length;
}
/// <summary>上报数据,针对命令</summary>
/// <param name="id"></param>
/// <returns></returns>
[HttpPost(nameof(Report))]
public async Task<Object> Report(Int32 id)
{
var node = _node ?? throw new ApiException(ApiCode.Unauthorized, "节点未登录");
var cmd = NodeCommand.FindById(id);
if (cmd != null && cmd.NodeID == node.ID)
{
var ms = Request.Body;
if (Request.ContentLength > 0)
{
var rs = cmd.Command switch
{
"截屏" => await SaveFileAsync(cmd, ms, "png"),
"抓日志" => await SaveFileAsync(cmd, ms, "log"),
_ => await SaveFileAsync(cmd, ms, "bin"),
};
if (!rs.IsNullOrEmpty())
{
cmd.Status = CommandStatus.;
cmd.Result = rs;
cmd.Save();
WriteLog(cmd.Command, true, rs);
}
}
}
return null;
}
private async Task<String> SaveFileAsync(NodeCommand cmd, Stream ms, String ext)
{
var file = $"../{cmd.Command}/{DateTime.Today:yyyyMMdd}/{cmd.NodeID}_{cmd.Id}.{ext}";
file.EnsureDirectory(true);
using var fs = file.AsFile().OpenWrite();
await ms.CopyToAsync(fs);
await ms.FlushAsync();
return file;
}
/// <summary>设备端响应服务调用</summary>
/// <param name="model">服务</param>
/// <returns></returns>
[HttpPost(nameof(CommandReply))]
public Int32 CommandReply(CommandReplyModel model) => _node == null ? throw new ApiException(ApiCode.Unauthorized, "节点未登录") : _nodeService.CommandReply(_node, model, Token);
#endregion
#region
/// <summary>下行通知。通知节点更新、安装和启停应用等</summary>
/// <returns></returns>
[HttpGet("/node/notify")]
public async Task Notify()
{
if (HttpContext.WebSockets.IsWebSocketRequest)
{
var ip = UserHost;
var token = (HttpContext.Request.Headers["Authorization"] + "").TrimStart("Bearer ");
using var socket = await HttpContext.WebSockets.AcceptWebSocketAsync();
try
{
await HandleNotify(socket, token, ip, HttpContext.RequestAborted);
}
catch (Exception ex)
{
XTrace.WriteLine("WebSocket异常 node={0} ip={1}", _node, ip);
XTrace.WriteException(ex);
WriteLog("Node/Notify", false, ex?.GetTrue() + "");
}
}
else
{
HttpContext.Response.StatusCode = 400;
}
}
private async Task HandleNotify(WebSocket socket, String token, String ip, CancellationToken cancellationToken)
{
var (_, node, error) = _nodeService.DecodeToken(token, _setting.TokenSecret);
_node = node ?? throw new ApiException(ApiCode.Unauthorized, $"未登录![ip={ip}]");
if (error != null) throw error;
using var session = new NodeCommandSession(socket)
{
Code = node.Code,
Log = this,
SetOnline = online => SetOnline(node, token, ip, online)
};
_sessionManager.Add(session);
await session.WaitAsync(HttpContext, cancellationToken).ConfigureAwait(false);
}
private void SetOnline(Node node, String token, String ip, Boolean online)
{
var olt = _nodeService.GetOrAddOnline(node, token, ip);
if (olt != null)
{
olt.WebSocket = online;
olt.Update();
}
}
/// <summary>向节点发送命令。通知节点更新、安装和启停应用等</summary>
/// <param name="model"></param>
/// <param name="token">应用令牌</param>
/// <returns></returns>
[AllowAnonymous]
[HttpPost(nameof(SendCommand))]
public async Task<Int32> SendCommand(CommandInModel model, String token)
{
if (model.Code.IsNullOrEmpty()) throw new ArgumentNullException(nameof(model.Code), "必须指定节点");
if (model.Command.IsNullOrEmpty()) throw new ArgumentNullException(nameof(model.Command));
var cmd = await _nodeService.SendCommand(model, token, _setting);
return cmd.Id;
}
#endregion
#region
private void WriteHistory(Node node, String action, Boolean success, DateTime time, String remark, String ip = null)
{
var hi = NodeHistory.Create(node ?? _node, action, success, remark, Environment.MachineName, ip ?? UserHost);
if (time.Year > 2000) hi.CreateTime = time;
hi.Insert();
}
#endregion
}