336 lines
12 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 AmtScanner.Api.Data;
using AmtScanner.Api.Models;
using AmtScanner.Api.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
namespace AmtScanner.Api.Controllers;
[ApiController]
[Route("api/remote-desktop")]
public class RemoteDesktopController : ControllerBase
{
private readonly IGuacamoleService _guacamoleService;
private readonly AppDbContext _context;
private readonly ILogger<RemoteDesktopController> _logger;
public RemoteDesktopController(
IGuacamoleService guacamoleService,
AppDbContext context,
ILogger<RemoteDesktopController> logger)
{
_guacamoleService = guacamoleService;
_context = context;
_logger = logger;
}
/// <summary>
/// 健康检查
/// </summary>
[HttpGet("health")]
public IActionResult Health()
{
return Ok(ApiResponse<object>.Success(new { status = "ok" }, "远程桌面服务正常"));
}
/// <summary>
/// 生成远程访问 Token管理员使用
/// </summary>
[HttpPost("generate-token/{deviceId}")]
public async Task<ActionResult<ApiResponse<GenerateTokenResponse>>> GenerateToken(
long deviceId,
[FromBody] GenerateTokenRequest request)
{
var device = await _context.AmtDevices.FindAsync(deviceId);
if (device == null)
return Ok(ApiResponse<GenerateTokenResponse>.Fail(404, "设备不存在"));
// 检查设备是否配置了 Windows 凭据
if (string.IsNullOrEmpty(device.WindowsUsername) || string.IsNullOrEmpty(device.WindowsPassword))
return Ok(ApiResponse<GenerateTokenResponse>.Fail(400, "请先为该设备配置 Windows 登录凭据"));
var token = GenerateRandomToken();
var expiresAt = DateTime.UtcNow.AddMinutes(request.ExpiresInMinutes ?? 30);
var accessToken = new RemoteAccessToken
{
Token = token,
DeviceId = deviceId,
ExpiresAt = expiresAt,
MaxUseCount = request.MaxUseCount ?? 1,
Note = request.Note
};
_context.RemoteAccessTokens.Add(accessToken);
await _context.SaveChangesAsync();
var baseUrl = $"{Request.Scheme}://{Request.Host}";
var accessUrl = $"{baseUrl}/#/remote/{token}";
_logger.LogInformation("Generated remote access token for device {Ip}, expires at {ExpiresAt}",
device.IpAddress, expiresAt);
return Ok(ApiResponse<GenerateTokenResponse>.Success(new GenerateTokenResponse
{
Success = true,
Token = token,
AccessUrl = accessUrl,
ExpiresAt = expiresAt,
MaxUseCount = accessToken.MaxUseCount,
DeviceIp = device.IpAddress
}, "Token 生成成功"));
}
/// <summary>
/// 通过 Token 连接远程桌面
/// </summary>
[HttpGet("connect-by-token/{token}")]
public async Task<ActionResult<ApiResponse<RemoteDesktopResponse>>> ConnectByToken(string token)
{
var accessToken = await _context.RemoteAccessTokens
.Include(t => t.Device)
.FirstOrDefaultAsync(t => t.Token == token);
if (accessToken == null)
return Ok(ApiResponse<RemoteDesktopResponse>.Fail(404, "无效的访问链接"));
if (!accessToken.IsValid())
return Ok(ApiResponse<RemoteDesktopResponse>.Fail(400, "访问链接已过期或已达到使用次数上限"));
if (accessToken.Device == null)
return Ok(ApiResponse<RemoteDesktopResponse>.Fail(400, "设备信息不完整"));
if (string.IsNullOrEmpty(accessToken.Device.WindowsUsername) || string.IsNullOrEmpty(accessToken.Device.WindowsPassword))
return Ok(ApiResponse<RemoteDesktopResponse>.Fail(400, "设备未配置 Windows 登录凭据"));
var password = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(accessToken.Device.WindowsPassword));
accessToken.UseCount++;
accessToken.UsedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
var guacToken = await _guacamoleService.GetAuthTokenAsync();
if (string.IsNullOrEmpty(guacToken))
return Ok(ApiResponse<RemoteDesktopResponse>.Fail(503, "无法连接到 Guacamole 服务"));
var connectionName = $"AMT-{accessToken.Device.IpAddress}";
var connectionId = await _guacamoleService.CreateOrGetConnectionAsync(
guacToken, connectionName, accessToken.Device.IpAddress,
accessToken.Device.WindowsUsername!, password);
if (string.IsNullOrEmpty(connectionId))
return Ok(ApiResponse<RemoteDesktopResponse>.Fail(500, "创建远程连接失败"));
var connectionUrl = await _guacamoleService.GetConnectionUrlAsync(guacToken, connectionId);
return Ok(ApiResponse<RemoteDesktopResponse>.Success(new RemoteDesktopResponse
{
Success = true,
ConnectionUrl = connectionUrl,
ConnectionId = connectionId,
Token = guacToken,
DeviceIp = accessToken.Device.IpAddress
}));
}
/// <summary>
/// 验证 Token 是否有效
/// </summary>
[HttpGet("validate-token/{token}")]
public async Task<ActionResult<ApiResponse<ValidateTokenResponse>>> ValidateToken(string token)
{
var accessToken = await _context.RemoteAccessTokens
.Include(t => t.Device)
.FirstOrDefaultAsync(t => t.Token == token);
if (accessToken == null)
return Ok(ApiResponse<ValidateTokenResponse>.Success(new ValidateTokenResponse { Valid = false, Error = "无效的访问链接" }));
if (!accessToken.IsValid())
return Ok(ApiResponse<ValidateTokenResponse>.Success(new ValidateTokenResponse { Valid = false, Error = "访问链接已过期或已达到使用次数上限" }));
return Ok(ApiResponse<ValidateTokenResponse>.Success(new ValidateTokenResponse
{
Valid = true,
DeviceIp = accessToken.Device?.IpAddress,
ExpiresAt = accessToken.ExpiresAt,
RemainingUses = accessToken.MaxUseCount > 0 ? accessToken.MaxUseCount - accessToken.UseCount : -1
}));
}
/// <summary>
/// 获取设备的所有有效 Token
/// </summary>
[HttpGet("list-tokens/{deviceId}")]
public async Task<ActionResult<ApiResponse<List<TokenInfoDto>>>> GetDeviceTokens(long deviceId)
{
var tokens = await _context.RemoteAccessTokens
.Where(t => t.DeviceId == deviceId && t.ExpiresAt > DateTime.UtcNow)
.OrderByDescending(t => t.CreatedAt)
.Select(t => new TokenInfoDto
{
Id = t.Id,
Token = t.Token,
CreatedAt = t.CreatedAt,
ExpiresAt = t.ExpiresAt,
MaxUseCount = t.MaxUseCount,
UseCount = t.UseCount,
Note = t.Note
})
.ToListAsync();
return Ok(ApiResponse<List<TokenInfoDto>>.Success(tokens));
}
/// <summary>
/// 撤销 Token
/// </summary>
[HttpDelete("revoke-token/{tokenId}")]
public async Task<ActionResult<ApiResponse<object>>> RevokeToken(long tokenId)
{
var token = await _context.RemoteAccessTokens.FindAsync(tokenId);
if (token == null)
return Ok(ApiResponse<object>.Fail(404, "Token 不存在"));
_context.RemoteAccessTokens.Remove(token);
await _context.SaveChangesAsync();
return Ok(ApiResponse<object>.Success(null, "Token 已撤销"));
}
/// <summary>
/// 清理过期 Token
/// </summary>
[HttpPost("cleanup-tokens")]
public async Task<ActionResult<ApiResponse<CleanupTokensResponse>>> CleanupExpiredTokens()
{
var count = await _context.RemoteAccessTokens
.Where(t => t.ExpiresAt < DateTime.UtcNow)
.ExecuteDeleteAsync();
return Ok(ApiResponse<CleanupTokensResponse>.Success(new CleanupTokensResponse { DeletedCount = count }, $"已清理 {count} 个过期 Token"));
}
/// <summary>
/// 直接连接(需要输入凭据)
/// </summary>
[HttpPost("connect/{deviceId}")]
public async Task<ActionResult<ApiResponse<RemoteDesktopResponse>>> Connect(long deviceId, [FromBody] RdpCredentials credentials)
{
var device = await _context.AmtDevices.FindAsync(deviceId);
if (device == null)
return Ok(ApiResponse<RemoteDesktopResponse>.Fail(404, "设备不存在"));
var guacToken = await _guacamoleService.GetAuthTokenAsync();
if (string.IsNullOrEmpty(guacToken))
return Ok(ApiResponse<RemoteDesktopResponse>.Fail(503, "无法连接到 Guacamole 服务"));
var connectionName = $"AMT-{device.IpAddress}";
var connectionId = await _guacamoleService.CreateOrGetConnectionAsync(
guacToken, connectionName, device.IpAddress, credentials.Username, credentials.Password);
if (string.IsNullOrEmpty(connectionId))
return Ok(ApiResponse<RemoteDesktopResponse>.Fail(500, "创建远程连接失败"));
var connectionUrl = await _guacamoleService.GetConnectionUrlAsync(guacToken, connectionId);
return Ok(ApiResponse<RemoteDesktopResponse>.Success(new RemoteDesktopResponse
{
Success = true,
ConnectionUrl = connectionUrl,
ConnectionId = connectionId,
Token = guacToken
}));
}
/// <summary>
/// 测试 Guacamole 连接
/// </summary>
[HttpGet("test")]
public async Task<ActionResult<ApiResponse<TestConnectionResponse>>> TestConnection()
{
var token = await _guacamoleService.GetAuthTokenAsync();
if (string.IsNullOrEmpty(token))
return Ok(ApiResponse<TestConnectionResponse>.Fail(503, "无法连接到 Guacamole 服务"));
return Ok(ApiResponse<TestConnectionResponse>.Success(new TestConnectionResponse { Success = true, Message = "Guacamole 服务连接正常" }));
}
private static string GenerateRandomToken()
{
var bytes = new byte[24];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes).Replace("+", "-").Replace("/", "_").Replace("=", "");
}
}
#region Request/Response Models
public class GenerateTokenRequest
{
public int? ExpiresInMinutes { get; set; } = 30;
public int? MaxUseCount { get; set; } = 1;
public string? Note { get; set; }
}
public class GenerateTokenResponse
{
public bool Success { get; set; }
public string Token { get; set; } = string.Empty;
public string AccessUrl { get; set; } = string.Empty;
public DateTime ExpiresAt { get; set; }
public int MaxUseCount { get; set; }
public string? DeviceIp { get; set; }
public string? Error { get; set; }
}
public class ValidateTokenResponse
{
public bool Valid { get; set; }
public string? DeviceIp { get; set; }
public DateTime? ExpiresAt { get; set; }
public int RemainingUses { get; set; }
public string? Error { get; set; }
}
public class TokenInfoDto
{
public long Id { get; set; }
public string Token { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime ExpiresAt { get; set; }
public int MaxUseCount { get; set; }
public int UseCount { get; set; }
public string? Note { get; set; }
}
public class RdpCredentials
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
public class RemoteDesktopResponse
{
public bool Success { get; set; }
public string? ConnectionUrl { get; set; }
public string? ConnectionId { get; set; }
public string? Token { get; set; }
public string? DeviceIp { get; set; }
public string? Error { get; set; }
}
public class CleanupTokensResponse
{
public int DeletedCount { get; set; }
}
public class TestConnectionResponse
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
}
#endregion