삽질하다 도저히 문제 파악이 안돼서 opcUaManager로 분리 테스트 중

This commit is contained in:
2026-02-25 08:52:03 +09:00
parent 4ea351946a
commit e88ab87771
138 changed files with 1051971 additions and 351 deletions

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -0,0 +1 @@
![alt text](ProjectSchema.png)

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
// using System.Linq;
using System.Text;
using System.Threading;
//using System.Threading;
using System.Threading.Tasks;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
@@ -21,6 +21,139 @@ namespace OpcConnectionTest
public string Description { get; set; } = "";
}
public class TagMaster
{
public string TagName {get; set; } = string.Empty;
public string FullNodeId {get; set; }= string.Empty;
public string NodeClass {get; set; }= string.Empty;
public string DataType {get; set; }= string.Empty;
public int Level {get; set; }
}
public class HoneywellCrawler
{
private readonly Session _session;
private readonly List<TagMaster> _discoveredTags = []; // IDE0028 적용
public HoneywellCrawler(Session session)
{
// 의존성 주입 및 Null 체크
_session = session ?? throw new ArgumentNullException(nameof(session));
}
/// <summary>
/// 저인망 탐사 시작점
/// </summary>
public async Task RunAsync(string startNodeId)
{
Console.WriteLine($"\n🚀 비동기 저인망 탐사 시작: {startNodeId}");
try
{
NodeId rootNode = NodeId.Parse(startNodeId);
await BrowseRecursiveAsync(rootNode, 0);
Console.WriteLine("\n===============================================");
Console.WriteLine($"✅ 탐사 완료! 총 {_discoveredTags.Count}개의 항목 발견.");
Console.WriteLine("===============================================");
SaveToCsv();
}
catch (Exception ex)
{
Console.WriteLine($"❌ 실행 중 치명적 오류: {ex.Message}");
}
}
private async Task BrowseRecursiveAsync(NodeId nodeId, int level)
{
try
{
BrowseDescription description = new() {
NodeId = nodeId,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = (uint)(NodeClass.Variable | NodeClass.Object),
ResultMask = (uint)BrowseResultMask.All
};
// 1. BrowseAsync는 객체 방식 (에러 CS1061/CS8129 방지)
BrowseResponse response = await _session.BrowseAsync(null, null, 0, [description], default);
if (response?.Results == null || response.Results.Count == 0) return;
foreach (var result in response.Results)
{
await ProcessReferencesAsync(result.References, level);
byte[] cp = result.ContinuationPoint;
while (cp != null && cp.Length > 0)
{
// 2. BrowseNextAsync는 튜플 방식 (에러 CS0029 방지)
var (nextHeader, nextCp, nextRefs) = await _session.BrowseNextAsync(null, false, cp, default);
await ProcessReferencesAsync(nextRefs, level);
cp = nextCp;
}
}
}
catch (Exception)
{
// 특정 노드 접근 권한 에러 등은 무시하고 진행
// Console.WriteLine($"⚠️ [Level {level}] {nodeId} 탐색 건너뜀: {ex.Message}");
}
}
private async Task ProcessReferencesAsync(ReferenceDescriptionCollection references, int level)
{
if (references == null || references.Count == 0) return;
foreach (var rd in references)
{
// ExpandedNodeId를 실제 NodeId로 변환 (Namespace 관리용)
NodeId childId = ExpandedNodeId.ToNodeId(rd.NodeId, _session.NamespaceUris);
// 마스터 리스트에 추가
_discoveredTags.Add(new TagMaster
{
TagName = rd.BrowseName.Name ?? "Unknown",
FullNodeId = childId.ToString(),
NodeClass = rd.NodeClass.ToString(),
Level = level
});
// 콘솔 출력 (진행 상황 확인용)
string indent = new string(' ', level * 2);
Console.WriteLine($"{indent} [{rd.NodeClass}] {rd.BrowseName.Name} (ID: {childId})");
// 3. Object(폴더/태그)인 경우 재귀 탐색 (하니웰 구조에 맞춰 깊이 5단계 제한)
if (rd.NodeClass == NodeClass.Object && level < 5)
{
await BrowseRecursiveAsync(childId, level + 1);
}
}
}
private void SaveToCsv()
{
try
{
using StreamWriter sw = new("Honeywell_FullMap.csv");
sw.WriteLine("Level,Class,Name,NodeId");
foreach (var tag in _discoveredTags)
{
sw.WriteLine($"{tag.Level},{tag.NodeClass},{tag.TagName},{tag.FullNodeId}");
}
Console.WriteLine($"💾 CSV 저장 완료: {Path.GetFullPath("Honeywell_FullMap.csv")}");
}
catch (Exception ex)
{
Console.WriteLine($"❌ CSV 저장 실패: {ex.Message}");
}
}
}
class Program
{
static Dictionary<string, StatusCodeInfo> _statusCodeMap = new(StringComparer.OrdinalIgnoreCase);
@@ -117,11 +250,30 @@ namespace OpcConnectionTest
var endpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
var identity = new UserIdentity(userName, Encoding.UTF8.GetBytes(password));
session = await Session.Create(config, endpoint, false, "OpcTestSession", 60000, identity, null);
#pragma warning disable CS0618
session = await Session.Create(
config,
endpoint,
false,
"OpcTestSession",
60000,
identity,
null
);
#pragma warning restore CS0618
Console.WriteLine($"✅ Connected! SessionID: {session.SessionId}");
// 3. 데이터 읽기 및 DB 저장 루프 (테스트용으로 5회 반복)
Console.WriteLine("\n🔍 하니웰 전체 노드 탐사(저인망) 시작...");
// ISession을 실제 Session 클래스로 형변환하여 전달
if (session is Session clientSession)
{
HoneywellCrawler crawler = new HoneywellCrawler(clientSession);
// 'shinam' 노드를 기점으로 바닥까지 훑고 CSV 저장까지 수행합니다.
await crawler.RunAsync("ns=1;s=$assetmodel");
}
// 3. 데이터 읽기 및 DB 저장 루프 (테스트용으로 5회 반복)
string nodeID = "ns=1;s=shinam:p-6102.hzset.fieldvalue";
for (int i = 0; i < 5; i++)
{

BIN
OpcConnectionTest/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

View File

@@ -13,10 +13,10 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("OpcConnectionTest")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+20ee22ae0c85a902f6767918a8ebece87301c37f")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+4ea351946aa5ffb76c8c693b768cb7f460f0cb79")]
[assembly: System.Reflection.AssemblyProductAttribute("OpcConnectionTest")]
[assembly: System.Reflection.AssemblyTitleAttribute("OpcConnectionTest")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
// MSBuild WriteCodeFragment 클래스에서 생성되었습니다.
// Generated by the MSBuild WriteCodeFragment class.

View File

@@ -1 +1 @@
f71e0f6a7ff186b47ef80c0b9a2fc70fce275730017c4f885ccba6c266a8ced1
5dc72724b0828bf3589118e6e5edcb9a4e4008e775a094b3a90862f8de1efba1

View File

@@ -1,3 +1,8 @@
/home/pacer/projects/OpcConnectionTest/obj/Debug/net8.0/OpcConnectionTest.csproj.AssemblyReference.cache
/home/pacer/projects/OpcConnectionTest/obj/Debug/net8.0/OpcConnectionTest.GeneratedMSBuildEditorConfig.editorconfig
/home/pacer/projects/OpcConnectionTest/obj/Debug/net8.0/OpcConnectionTest.AssemblyInfoInputs.cache
/home/pacer/projects/OpcConnectionTest/obj/Debug/net8.0/OpcConnectionTest.AssemblyInfo.cs
/home/pacer/projects/OpcConnectionTest/obj/Debug/net8.0/OpcConnectionTest.csproj.CoreCompileInputs.cache
/home/pacer/projects/OpcConnectionTest/bin/Debug/net8.0/OpcConnectionTest
/home/pacer/projects/OpcConnectionTest/bin/Debug/net8.0/OpcConnectionTest.deps.json
/home/pacer/projects/OpcConnectionTest/bin/Debug/net8.0/OpcConnectionTest.runtimeconfig.json
@@ -11,6 +16,7 @@
/home/pacer/projects/OpcConnectionTest/bin/Debug/net8.0/Microsoft.Extensions.Options.dll
/home/pacer/projects/OpcConnectionTest/bin/Debug/net8.0/Microsoft.Extensions.Primitives.dll
/home/pacer/projects/OpcConnectionTest/bin/Debug/net8.0/Newtonsoft.Json.dll
/home/pacer/projects/OpcConnectionTest/bin/Debug/net8.0/Npgsql.dll
/home/pacer/projects/OpcConnectionTest/bin/Debug/net8.0/Opc.Ua.Client.dll
/home/pacer/projects/OpcConnectionTest/bin/Debug/net8.0/Opc.Ua.Configuration.dll
/home/pacer/projects/OpcConnectionTest/bin/Debug/net8.0/Opc.Ua.Core.dll
@@ -26,15 +32,9 @@
/home/pacer/projects/OpcConnectionTest/bin/Debug/net8.0/System.Text.Encodings.Web.dll
/home/pacer/projects/OpcConnectionTest/bin/Debug/net8.0/System.Text.Json.dll
/home/pacer/projects/OpcConnectionTest/bin/Debug/net8.0/runtimes/browser/lib/net8.0/System.Text.Encodings.Web.dll
/home/pacer/projects/OpcConnectionTest/obj/Debug/net8.0/OpcConnectionTest.csproj.AssemblyReference.cache
/home/pacer/projects/OpcConnectionTest/obj/Debug/net8.0/OpcConnectionTest.GeneratedMSBuildEditorConfig.editorconfig
/home/pacer/projects/OpcConnectionTest/obj/Debug/net8.0/OpcConnectionTest.AssemblyInfoInputs.cache
/home/pacer/projects/OpcConnectionTest/obj/Debug/net8.0/OpcConnectionTest.AssemblyInfo.cs
/home/pacer/projects/OpcConnectionTest/obj/Debug/net8.0/OpcConnectionTest.csproj.CoreCompileInputs.cache
/home/pacer/projects/OpcConnectionTest/obj/Debug/net8.0/OpcConnectionTest.csproj.CopyComplete
/home/pacer/projects/OpcConnectionTest/obj/Debug/net8.0/OpcConnectionTest.dll
/home/pacer/projects/OpcConnectionTest/obj/Debug/net8.0/refint/OpcConnectionTest.dll
/home/pacer/projects/OpcConnectionTest/obj/Debug/net8.0/OpcConnectionTest.pdb
/home/pacer/projects/OpcConnectionTest/obj/Debug/net8.0/OpcConnectionTest.genruntimeconfig.cache
/home/pacer/projects/OpcConnectionTest/obj/Debug/net8.0/ref/OpcConnectionTest.dll
/home/pacer/projects/OpcConnectionTest/bin/Debug/net8.0/Npgsql.dll

View File

@@ -0,0 +1,221 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Net;
using System.Net.NetworkInformation;
using OpcPks.Core.Models;
using System.Diagnostics;
using System.Text;
namespace OpcPks.Core.Services
{
public class CertificateGenerator
{
private readonly string _baseDataPath;
private readonly OpcSessionManager _sessionManager;
public CertificateGenerator(string baseDataPath, OpcSessionManager sessionManager)
{
_baseDataPath = baseDataPath;
_sessionManager = sessionManager;
}
public async Task<bool> CreateHoneywellCertificateAsync(CertRequestModel model)
{
try
{
string appName = "OpcPksClient";
// 초기 성공 모델과 동일한 URI 패턴 유지
string applicationUri = $"urn:{Environment.MachineName}:OpcPksClient";
string derPath = Path.Combine(_baseDataPath, "own", "certs", $"{appName}.der");
string pfxPath = Path.Combine(_baseDataPath, "own", "private", $"{appName}.pfx");
Directory.CreateDirectory(Path.GetDirectoryName(derPath)!);
Directory.CreateDirectory(Path.GetDirectoryName(pfxPath)!);
// 1. RSA 키 생성 (2048-bit)
using var rsa = RSA.Create(2048);
// 2. 인증서 요청 생성
var request = new CertificateRequest(
$"CN={appName}",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
// 3. [Critical] 하니웰이 요구하는 '신분증 지문' 및 '도장' 추가
// A. Subject Key Identifier (이게 없으면 하니웰 엔진이 인증서를 무시합니다)
request.CertificateExtensions.Add(
new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
// B. Subject Alternative Name (SAN)
var sanBuilder = new SubjectAlternativeNameBuilder();
sanBuilder.AddUri(new Uri(applicationUri));
sanBuilder.AddDnsName(Environment.MachineName);
sanBuilder.AddDnsName("localhost");
foreach (var ip in GetLocalIpAddresses()) sanBuilder.AddIpAddress(ip);
request.CertificateExtensions.Add(sanBuilder.Build());
// C. Key Usage (하니웰 보안 4종 세트)
request.CertificateExtensions.Add(new X509KeyUsageExtension(
X509KeyUsageFlags.DigitalSignature |
X509KeyUsageFlags.KeyEncipherment |
X509KeyUsageFlags.NonRepudiation |
X509KeyUsageFlags.DataEncipherment,
true));
// D. Enhanced Key Usage (EKU)
request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
new OidCollection {
new Oid("1.3.6.1.5.5.7.3.1"), // Server Auth
new Oid("1.3.6.1.5.5.7.3.2") // Client Auth
}, false));
// E. Basic Constraints (End Entity 명시)
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
// 4. 인증서 생성 (초기 코드처럼 넉넉하게 10년)
using var certificate = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow.AddYears(10));
// 5. 파일 저장
// PFX는 암호 없이 추출 (호환성)
await File.WriteAllBytesAsync(pfxPath, certificate.Export(X509ContentType.Pfx));
// DER은 하니웰 서버 전송용
await File.WriteAllBytesAsync(derPath, certificate.Export(X509ContentType.Cert));
Console.WriteLine($"✅ [Step 1] 하니웰 지문(SKI) 포함 인증서 제작 완료: {derPath}");
// 6. 전송 및 검증
string primaryIp = model.PrimaryIpA?.Trim() ?? "";
if (string.IsNullOrEmpty(primaryIp)) throw new Exception("Primary IP 주소가 비어있습니다.");
await TransferAndVerifyWithRetryAsync(model, primaryIp, $"{appName}.der", derPath);
return true;
}
catch (Exception ex)
{
Console.WriteLine($"❌ 작업 최종 실패: {ex.Message}");
throw;
}
}
private async Task TransferAndVerifyWithRetryAsync(CertRequestModel model, string ip, string fileName, string localPath)
{
string winTrustedDir = @"ProgramData\Honeywell\Experion PKS\Server\data\CertStore\opcuaserver\pki\DefaultApplicationGroup\trusted\certs";
string winRejectedDir = @"ProgramData\Honeywell\Experion PKS\Server\data\CertStore\opcuaserver\pki\DefaultApplicationGroup\rejected\certs";
string auth = $"{model.AdminId}%{model.AdminPassword}";
int maxRetries = 3;
bool isAuthorized = false;
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
Console.WriteLine($"\n🔄 [{ip}] {attempt}회차 시퀀스 시작...");
string trustedFile = $"{winTrustedDir}\\{fileName}";
string rejectedFile = $"{winRejectedDir}\\{fileName}";
// 1. 기존 파일 삭제 및 새 인증서 전송
await DeleteFileFromHoneywell(ip, trustedFile, auth);
await PutFileToHoneywell(ip, localPath, trustedFile, auth);
await Task.Delay(3000);
// 2. 접속 시도 (하니웰 반응 유도)
isAuthorized = await _sessionManager.SayHelloAsync(ip);
if (isAuthorized)
{
Console.WriteLine($"🎯 [{ip}] 인증 성공!");
return;
}
Console.WriteLine($"⚠️ [{ip}] 거절됨(0x80830000). Rejected 폴더 확인 중...");
await Task.Delay(2000);
// 3. Rejected 유배지로 갔다면 강제 이동 (하니웰의 '불신' 극복)
if (await CheckFileExists(ip, rejectedFile, auth))
{
Console.WriteLine($"🚩 [{ip}] Rejected 유배 확인! Trusted로 강제 이동합니다.");
string moveArgs = $"//{ip}/C$ -U \"{auth}\" -c \"rename \\\"{rejectedFile}\\\" \\\"{trustedFile}\\\"\"";
await ExecuteSmbCommandAsync(moveArgs);
await Task.Delay(2000);
if (await _sessionManager.SayHelloAsync(ip)) return;
}
}
if (!isAuthorized) throw new Exception($"❌ [{ip}] 지문 인식 실패 또는 Extensions 불일치. 수동 Trust 필요.");
}
private async Task PutFileToHoneywell(string ip, string localPath, string remotePath, string auth)
{
string args = $"//{ip}/C$ -U \"{auth}\" -c \"put \\\"{localPath}\\\" \\\"{remotePath}\\\"\"";
await ExecuteSmbCommandAsync(args);
}
private async Task DeleteFileFromHoneywell(string ip, string remotePath, string auth)
{
try {
string args = $"//{ip}/C$ -U \"{auth}\" -c \"del \\\"{remotePath}\\\"\"";
await ExecuteSmbCommandAsync(args);
} catch { }
}
private async Task<bool> CheckFileExists(string ip, string remotePath, string auth)
{
try {
string args = $"//{ip}/C$ -U \"{auth}\" -c \"allinfo \\\"{remotePath}\\\"\"";
string result = await ExecuteSmbCommandWithOutputAsync(args);
return !result.Contains("NT_STATUS_OBJECT_NAME_NOT_FOUND");
} catch { return false; }
}
private async Task ExecuteSmbCommandAsync(string args)
{
using var process = new Process {
StartInfo = new ProcessStartInfo {
FileName = "smbclient",
Arguments = args,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
string error = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
if (process.ExitCode != 0 && !error.Contains("NT_STATUS_OBJECT_NAME_NOT_FOUND"))
throw new Exception($"SMB 실패: {error.Trim()}");
}
private async Task<string> ExecuteSmbCommandWithOutputAsync(string args)
{
using var process = new Process {
StartInfo = new ProcessStartInfo {
FileName = "smbclient",
Arguments = args,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
string output = await process.StandardOutput.ReadToEndAsync();
string error = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
return output + error;
}
private List<IPAddress> GetLocalIpAddresses()
{
return NetworkInterface.GetAllNetworkInterfaces()
.Where(i => i.OperationalStatus == OperationalStatus.Up)
.SelectMany(i => i.GetIPProperties().UnicastAddresses)
.Where(a => a.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
.Select(a => a.Address).ToList();
}
}
}

Binary file not shown.

View File

@@ -13,10 +13,10 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("OpcPks.Collector")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+4e006a5a5f65f597fafaa3777b8a1073944eada2")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+4ea351946aa5ffb76c8c693b768cb7f460f0cb79")]
[assembly: System.Reflection.AssemblyProductAttribute("OpcPks.Collector")]
[assembly: System.Reflection.AssemblyTitleAttribute("OpcPks.Collector")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
// MSBuild WriteCodeFragment 클래스에서 생성되었습니다.
// Generated by the MSBuild WriteCodeFragment class.

View File

@@ -1 +1 @@
cc5c3e28019345878790a222ce17c89ce44d1c1d74447a18859ea42c9a3adb70
54f6f1ad0709deb6cb2f3668e2c474e9539ddf45ae288d8f7bae089724875a72

View File

@@ -1,20 +1,28 @@
using System.ComponentModel.DataAnnotations;
namespace OpcPks.Core.Models
{
public class CertRequestModel
{
// UI에서 asp-for가 이 이름을 찾습니다.
[Required]
public string AdminId { get; set; } = "mngr";
[Required]
[DataType(DataType.Password)]
public string AdminPassword { get; set; } = "mngr";
public string ApplicationName { get; set; } = "OpcPksClient";
public bool IsRedundant { get; set; } = false;
// 서버 호스트네임 (예: HONPKS)
public string PrimaryHostName { get; set; } = "";
public string SecondaryHostName { get; set; } = ""; // Redundant일 때 필수
public string PrimaryHostName { get; set; } = "192.168.0.20";
public string SecondaryHostName { get; set; } = "192.168.1.20";
// FTE 네트워크 구조 반영 (네트워크 대역 분리)
public string PrimaryIpA { get; set; } = ""; // 192.168.0.x 대역
public string PrimaryIpB { get; set; } = ""; // 192.168.1.x 대역 (FTE 이중화 시)
public string? PrimaryIpA { get; set; } = ""; // Yellow
public string? PrimaryIpB { get; set; } = ""; // Green
public string SecondaryIpA { get; set; } = ""; // 192.168.0.y 대역
public string SecondaryIpB { get; set; } = ""; // 192.168.1.y 대역
public string SecondaryIpA { get; set; } = "";
public string SecondaryIpB { get; set; } = "";
public int ValidityYears { get; set; } = 5;
}

View File

@@ -1,120 +1,96 @@
using Opc.Ua;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Net;
using System.Net.NetworkInformation;
using OpcPks.Core.Models;
using System.Diagnostics;
namespace OpcPks.Core.Services
{
public class CertificateGenerator
{
private readonly string _pfxPath;
private readonly string _ownCertPath;
private readonly string _baseDataPath;
private readonly OpcSessionManager _sessionManager;
public CertificateGenerator(string baseDataPath)
public CertificateGenerator(string baseDataPath, OpcSessionManager sessionManager)
{
// 경로 설정 (현재 tree 구조 반영)
_pfxPath = Path.Combine(baseDataPath, "pki/own/private/OpcTestClient.pfx");
_ownCertPath = Path.Combine(baseDataPath, "pki/own/certs/OpcTestClient.der");
_baseDataPath = baseDataPath;
_sessionManager = sessionManager;
}
public async Task<bool> CreateHoneywellCertificateAsync(CertRequestModel model)
{
try
{
// 1. [중요] 기존 인증서 백업
BackupExistingCertificate();
Console.WriteLine("🔐 하니웰 맞춤형 인증서 생성을 시작합니다...");
// SAN 리스트 구축 (localhost 기본 포함)
var subjectAlternativeNames = new List<string> { "localhost", "127.0.0.1" };
string appName = "OpcPksClient";
// [중요] 호스트네임은 대문자로 처리 (윈도우 기본값 준수)
string hostName = Environment.MachineName.ToUpper();
string applicationUri = $"urn:{hostName}:{appName}";
// Primary 정보 (FTE Yellow/Green)
if (!string.IsNullOrEmpty(model.PrimaryHostName))
{
subjectAlternativeNames.Add(model.PrimaryHostName);
subjectAlternativeNames.Add($"{model.PrimaryHostName}A");
}
if (!string.IsNullOrEmpty(model.PrimaryIpA)) subjectAlternativeNames.Add(model.PrimaryIpA);
if (!string.IsNullOrEmpty(model.PrimaryIpB)) subjectAlternativeNames.Add(model.PrimaryIpB);
string derPath = Path.Combine(_baseDataPath, "own", "certs", $"{appName}.der");
string pfxPath = Path.Combine(_baseDataPath, "own", "private", $"{appName}.pfx");
// Secondary 정보 (Redundant 선택 시)
if (model.IsRedundant)
{
if (!string.IsNullOrEmpty(model.SecondaryHostName))
{
subjectAlternativeNames.Add(model.SecondaryHostName);
subjectAlternativeNames.Add($"{model.SecondaryHostName}B");
}
if (!string.IsNullOrEmpty(model.SecondaryIpA)) subjectAlternativeNames.Add(model.SecondaryIpA);
if (!string.IsNullOrEmpty(model.SecondaryIpB)) subjectAlternativeNames.Add(model.SecondaryIpB);
}
Directory.CreateDirectory(Path.GetDirectoryName(derPath)!);
Directory.CreateDirectory(Path.GetDirectoryName(pfxPath)!);
// 2. 인증서 생성 (KeySize 2048, 하니웰 규격)
// var certificate = CertificateFactory.CreateCertificate(
// model.ApplicationName,
// model.ApplicationName,
// null,
// subjectAlternativeNames,
// null,
// 2048,
// DateTime.UtcNow.AddDays(-1),
// model.ValidityYears * 12,
// null
// );
using var rsa = RSA.Create(2048);
// [교정] 오직 CN만 포함 (Configuration Error 방지를 위해 불필요한 필드 제거)
var request = new CertificateRequest(
$"CN={appName}",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
// 2. 인증서 생성 (로그에 표시된 15개 인자 서명 순서 엄격 준수)
ushort keySize = 2048;
ushort lifetimeInMonths = (ushort)(model.ValidityYears * 12);
// 1. Subject Key Identifier (필수 지문)
request.CertificateExtensions.Add(
new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
var certificate = CertificateFactory.CreateCertificate(
"Directory", // 1. storeType (string)
Path.GetDirectoryName(_pfxPath), // 2. storePath (string)
null, // 3. password (string)
$"urn:localhost:{model.ApplicationName}", // 4. applicationUri (string)
model.ApplicationName, // 5. applicationName (string)
model.ApplicationName, // 6. subjectName (string)
subjectAlternativeNames, // 7. domainNames (IList<string>)
keySize, // 8. keySize (ushort)
DateTime.UtcNow.AddDays(-1), // 9. startTime (DateTime)
lifetimeInMonths, // 10. lifetimeInMonths (ushort)
(ushort)256, // 11. hashSizeInBits (ushort)
false, // 12. isCA (bool)
null, // 13. issuer (X509Certificate2)
null, // 14. privateKey (byte[])
0 // 15. hashAlgorithm (int)
);
// 2. SAN (URI와 DNS 단 하나씩만 - 하니웰의 해석 오류 방지)
var sanBuilder = new SubjectAlternativeNameBuilder();
sanBuilder.AddUri(new Uri(applicationUri));
sanBuilder.AddDnsName(hostName);
request.CertificateExtensions.Add(sanBuilder.Build());
// 3. Key Usage (가장 표준적인 3종으로 축소)
// NonRepudiation 플래그가 가끔 BadConfiguration을 유발하므로 뺍니다.
request.CertificateExtensions.Add(new X509KeyUsageExtension(
X509KeyUsageFlags.DigitalSignature |
X509KeyUsageFlags.KeyEncipherment |
X509KeyUsageFlags.DataEncipherment,
true));
// 3. 파일 저장
Directory.CreateDirectory(Path.GetDirectoryName(_pfxPath)!);
Directory.CreateDirectory(Path.GetDirectoryName(_ownCertPath)!);
// 4. Enhanced Key Usage
request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
new OidCollection {
new Oid("1.3.6.1.5.5.7.3.1"), // Server Auth
new Oid("1.3.6.1.5.5.7.3.2") // Client Auth
}, false));
// PFX (개인키 포함) 저장
byte[] pfxBytes = certificate.Export(X509ContentType.Pfx, "");
await File.WriteAllBytesAsync(_pfxPath, pfxBytes);
// 5. Basic Constraints (End Entity 명시)
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
// DER (공개키만) 저장 - 하니웰 서버에 수동으로 신뢰 등록할 때 필요할 수 있음
byte[] derBytes = certificate.Export(X509ContentType.Cert);
await File.WriteAllBytesAsync(_ownCertPath, derBytes);
// [교정] 시작 시간을 1시간 전으로 설정하여 하니웰 서버와의 시간차 방어
using var certificate = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddHours(-1),
DateTimeOffset.UtcNow.AddYears(5));
Console.WriteLine($"✅ 새 인증서 생성 완료 및 백업 성공.");
return true;
await File.WriteAllBytesAsync(pfxPath, certificate.Export(X509ContentType.Pfx));
await File.WriteAllBytesAsync(derPath, certificate.Export(X509ContentType.Cert));
Console.WriteLine($"\n--- [수동 작업 지시: mngr 계정 모드] ---");
Console.WriteLine($"1. 파일 생성 완료: {derPath}");
Console.WriteLine($"2. 하니웰 서버의 Trusted/certs 폴더에 기존 파일을 지우고 새 파일을 넣으세요.");
Console.WriteLine($"3. 'mngr' 계정의 권한으로 복사가 완료되면 엔터를 치세요.");
Console.ReadLine();
string primaryIp = model.PrimaryIpA?.Trim() ?? "";
// 바로 접속 테스트 진행
return await _sessionManager.SayHelloAsync(primaryIp);
}
catch (Exception ex)
{
Console.WriteLine($"❌ 인증서 생성 실패: {ex.Message}");
return false;
}
}
private void BackupExistingCertificate()
{
if (File.Exists(_pfxPath))
{
string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
string backupPath = _pfxPath + "." + timestamp + ".bak";
File.Copy(_pfxPath, backupPath, true);
Console.WriteLine($"📦 기존 인증서 백업됨: {Path.GetFileName(backupPath)}");
Console.WriteLine($"❌ 작업 실패: {ex.Message}");
throw;
}
}
}

View File

@@ -66,7 +66,7 @@ namespace OpcPks.Core.Services
{
await ProcessReferencesAsync(result.References, level);
byte[] cp = result.ContinuationPoint;
byte[]? cp = result.ContinuationPoint;
while (cp != null && cp.Length > 0)
{
// ✅ ByteStringCollection을 사용하여 라이브러리 표준 인자 타입 일치화
@@ -122,7 +122,7 @@ namespace OpcPks.Core.Services
try
{
// 디렉토리가 없으면 생성
string dir = Path.GetDirectoryName(filePath);
string? dir = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{
Directory.CreateDirectory(dir);

View File

@@ -1,108 +1,108 @@
using Opc.Ua;
using Opc.Ua.Client;
using System.Security.Cryptography.X509Certificates;
using System.Net;
using System;
using System.Threading.Tasks;
using OpcPks.Core.Models;
namespace OpcPks.Core.Services
{
public class OpcSessionManager
{
private ISession? _session;
public ISession? Session => _session;
private readonly string _pkiPath = "/home/pacer/projects/OpcPksPlatform/OpcPks.Core/Data/pki";
public async Task<ISession> GetSessionAsync()
/// <summary>
/// [수정] 하니웰 인사용: 단순히 연결만 하는 게 아니라 서버 시간을 읽으려 시도하여
/// 하니웰 본체 엔진이 인증서를 Rejected로 던지도록 유도합니다.
/// </summary>
public async Task<bool> SayHelloAsync(string ip)
{
if (_session != null && _session.Connected) return _session;
try
{
// CertRequestModel은 최소 정보만 담아 전달
using (var session = await GetSessionAsync(ip, new CertRequestModel { PrimaryIpA = ip }))
{
if (session != null && session.Connected)
{
// 연결 성공 시 서버 시간 노드를 읽어 실제 통신이 가능한지 확인
var currentTime = session.ReadValue(Variables.Server_ServerStatus_CurrentTime);
Console.WriteLine($"✅ [SayHello] 하니웰 연결 및 데이터 읽기 성공: {currentTime}");
await session.CloseAsync();
return true;
}
}
return false;
}
catch (Exception ex)
{
// 여기서 에러가 나야 정상입니다. (인증서가 신뢰되지 않았을 때 하니웰이 거부하며 파일을 Rejected로 보냄)
Console.WriteLine($"⚠️ [SayHello] 하니웰 거부 반응 유도 성공: {ex.Message}");
return false;
}
}
string serverHostName = "192.168.0.20";
string endpointUrl = $"opc.tcp://{serverHostName}:4840";
string applicationUri = "urn:dbsvr:OpcTestClient";
string userName = "mngr";
string password = "mngr";
string pfxPassword = ""; // openssl에서 확인한 비밀번호가 있다면 입력
/// <summary>
/// [핵심] 크롤러 및 모든 서비스가 공통으로 사용하는 실제 세션 생성 로직
/// </summary>
public async Task<ISession?> GetSessionAsync(string ip, CertRequestModel model)
{
if (string.IsNullOrEmpty(ip)) return null;
string baseDataPath = "/home/pacer/projects/OpcPksPlatform/OpcPks.Core/Data/pki";
string pfxFilePath = Path.Combine(baseDataPath, "own/private/OpcTestClient.pfx");
var config = new ApplicationConfiguration {
ApplicationName = "OpcTestClient",
string endpointUrl = $"opc.tcp://{ip}:4840";
var config = new ApplicationConfiguration()
{
ApplicationName = "OpcPksClient",
ApplicationType = ApplicationType.Client,
ApplicationUri = applicationUri,
SecurityConfiguration = new SecurityConfiguration {
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier {
StoreType = "Directory",
StorePath = Path.Combine(baseDataPath, "own"),
SubjectName = "OpcTestClient"
StorePath = $"{_pkiPath}/own"
},
TrustedPeerCertificates = new CertificateTrustList {
StoreType = "Directory",
StorePath = Path.Combine(baseDataPath, "trusted")
StorePath = $"{_pkiPath}/trusted"
},
AutoAcceptUntrustedCertificates = true,
AddAppCertToTrustedStore = true
RejectedCertificateStore = new CertificateTrustList {
StoreType = "Directory",
StorePath = $"{_pkiPath}/rejected"
},
AutoAcceptUntrustedCertificates = true
},
TransportQuotas = new TransportQuotas { OperationTimeout = 60000 },
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 }
TransportQuotas = new TransportQuotas { OperationTimeout = 10000 },
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 10000 }
};
// 1. [인증서 강제 로드] Validate 호출 전에 인증서를 명시적으로 로드합니다.
if (!File.Exists(pfxFilePath)) {
throw new Exception($"❌ PFX 파일을 찾을 수 없습니다: {pfxFilePath}");
}
try {
// 리눅스 .NET 호환용 플래그
var cert = new X509Certificate2(
pfxFilePath,
pfxPassword,
X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet
);
// config에 인증서 직접 주입
config.SecurityConfiguration.ApplicationCertificate.Certificate = cert;
Console.WriteLine($"✅ [인증서 로드] {cert.Subject}");
} catch (Exception ex) {
Console.WriteLine($"❌ [인증서 에러] PFX 로드 실패: {ex.Message}");
throw;
}
// 2. 설정 검증 (인증서 주입 후 실행)
await config.Validate(ApplicationType.Client);
// Validate 이후 인증서가 풀렸을 경우를 대비해 다시 확인
if (config.SecurityConfiguration.ApplicationCertificate.Certificate == null) {
Console.WriteLine("⚠️ [경고] Validate 과정에서 인증서 설정이 유실되어 재할당합니다.");
config.SecurityConfiguration.ApplicationCertificate.Certificate = new X509Certificate2(pfxFilePath, pfxPassword, X509KeyStorageFlags.MachineKeySet);
try
{
// 하니웰 보안 설정에 맞는 엔드포인트 선택
var endpointDescription = CoreClientUtils.SelectEndpoint(endpointUrl, true, 5000);
var endpointConfiguration = EndpointConfiguration.Create(config);
var endpoint = new ConfiguredEndpoint(null, endpointDescription, endpointConfiguration);
// 세션 생성 시도: 이 과정 자체가 하니웰 본체에 대한 보안 컨택입니다.
return await Session.Create(
config,
endpoint,
false,
"OpcPksSession",
10000,
new UserIdentity(new AnonymousIdentityToken()),
null);
}
config.CertificateValidator.CertificateValidation += (s, e) => { e.Accept = true; };
// 3. 엔드포인트 선택 및 보안 정책 로그
var endpointDescription = CoreClientUtils.SelectEndpoint(endpointUrl, true);
var endpointConfiguration = EndpointConfiguration.Create(config);
var configuredEndpoint = new ConfiguredEndpoint(null, endpointDescription, endpointConfiguration);
Console.WriteLine($"📡 [세션] 연결 시도: {endpointUrl}");
Console.WriteLine($"🔐 [보안] {endpointDescription.SecurityPolicyUri} ({endpointDescription.SecurityMode})");
// 4. 세션 생성 시도
try {
_session = await Opc.Ua.Client.Session.Create(
config,
configuredEndpoint,
true,
"OpcPksSession",
60000,
new UserIdentity(userName, password),
null
);
Console.WriteLine("🚀 [성공] 하니웰 서버와 연결되었습니다!");
} catch (ServiceResultException srex) {
Console.WriteLine($"❌ [OPC 에러] {srex.StatusCode}: {srex.Message}");
throw;
catch (ServiceResultException sx)
{
Console.WriteLine($"[OPC_UA] 하니웰 보안 응답 코드: {sx.StatusCode}");
return null;
}
catch (Exception ex)
{
Console.WriteLine($"[OPC_UA] 세션 생성 중 오류: {ex.Message}");
return null;
}
return _session;
}
}
}

View File

@@ -13,10 +13,10 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("OpcPks.Core")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+4e006a5a5f65f597fafaa3777b8a1073944eada2")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+4ea351946aa5ffb76c8c693b768cb7f460f0cb79")]
[assembly: System.Reflection.AssemblyProductAttribute("OpcPks.Core")]
[assembly: System.Reflection.AssemblyTitleAttribute("OpcPks.Core")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
// MSBuild WriteCodeFragment 클래스에서 생성되었습니다.
// Generated by the MSBuild WriteCodeFragment class.

View File

@@ -1 +1 @@
c75e210b6893d92e0b1636a6b8f564c5a308ff97701a72ccf681ea4473190030
a4e824e0dedb3502ec3664daca8c46528956a8b9400ccad8204c7aed55480ce3

View File

@@ -1 +1 @@
cea4cbfb0b4d3fd205bf727dfb75d0c31872eee74f74e79b0a11980153badb1b
bf8b9457af309b3d030af6437e28d995c9af82eaaa8c16419bd5b4f60d93749a

View File

@@ -10,11 +10,11 @@
"projectUniqueName": "/home/pacer/projects/OpcPksPlatform/OpcPks.Core/OpcPks.Core.csproj",
"projectName": "OpcPks.Core",
"projectPath": "/home/pacer/projects/OpcPksPlatform/OpcPks.Core/OpcPks.Core.csproj",
"packagesPath": "/root/.nuget/packages/",
"packagesPath": "/home/pacer/.nuget/packages/",
"outputPath": "/home/pacer/projects/OpcPksPlatform/OpcPks.Core/obj/",
"projectStyle": "PackageReference",
"configFilePaths": [
"/root/.nuget/NuGet/NuGet.Config"
"/home/pacer/.nuget/NuGet/NuGet.Config"
],
"originalTargetFrameworks": [
"net8.0"

View File

@@ -4,12 +4,12 @@
<RestoreSuccess Condition=" '$(RestoreSuccess)' == '' ">True</RestoreSuccess>
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">/root/.nuget/packages/</NuGetPackageRoot>
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">/root/.nuget/packages/</NuGetPackageFolders>
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">/home/pacer/.nuget/packages/</NuGetPackageRoot>
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">/home/pacer/.nuget/packages/</NuGetPackageFolders>
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">6.8.1</NuGetToolVersion>
</PropertyGroup>
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<SourceRoot Include="/root/.nuget/packages/" />
<SourceRoot Include="/home/pacer/.nuget/packages/" />
</ItemGroup>
</Project>

View File

@@ -555,7 +555,7 @@
]
},
"packageFolders": {
"/root/.nuget/packages/": {}
"/home/pacer/.nuget/packages/": {}
},
"project": {
"version": "1.0.0",
@@ -563,11 +563,11 @@
"projectUniqueName": "/home/pacer/projects/OpcPksPlatform/OpcPks.Core/OpcPks.Core.csproj",
"projectName": "OpcPks.Core",
"projectPath": "/home/pacer/projects/OpcPksPlatform/OpcPks.Core/OpcPks.Core.csproj",
"packagesPath": "/root/.nuget/packages/",
"packagesPath": "/home/pacer/.nuget/packages/",
"outputPath": "/home/pacer/projects/OpcPksPlatform/OpcPks.Core/obj/",
"projectStyle": "PackageReference",
"configFilePaths": [
"/root/.nuget/NuGet/NuGet.Config"
"/home/pacer/.nuget/NuGet/NuGet.Config"
],
"originalTargetFrameworks": [
"net8.0"

View File

@@ -1,19 +1,19 @@
{
"version": 2,
"dgSpecHash": "Yr08mh1oXg2H9eup6pJDv3DAEJIeIlBI+QyHLkFeXakigkBdjFhrDkS0UWc3z6IMWBK5yeeRM5RBBASJtTIrbA==",
"dgSpecHash": "4hhhTNv5rdj9W8LEgtqRzvNnobbH+nWA4apshlgfd3j4OdsEpRlMXC2Q3X2ScEegniOPFgZFJJ9l+i9qtr7LrA==",
"success": true,
"projectFilePath": "/home/pacer/projects/OpcPksPlatform/OpcPks.Core/OpcPks.Core.csproj",
"expectedPackageFiles": [
"/root/.nuget/packages/microsoft.extensions.dependencyinjection.abstractions/8.0.1/microsoft.extensions.dependencyinjection.abstractions.8.0.1.nupkg.sha512",
"/root/.nuget/packages/microsoft.extensions.logging.abstractions/8.0.1/microsoft.extensions.logging.abstractions.8.0.1.nupkg.sha512",
"/root/.nuget/packages/newtonsoft.json/13.0.3/newtonsoft.json.13.0.3.nupkg.sha512",
"/root/.nuget/packages/npgsql/8.0.4/npgsql.8.0.4.nupkg.sha512",
"/root/.nuget/packages/opcfoundation.netstandard.opc.ua.client/1.5.374.78/opcfoundation.netstandard.opc.ua.client.1.5.374.78.nupkg.sha512",
"/root/.nuget/packages/opcfoundation.netstandard.opc.ua.configuration/1.5.374.78/opcfoundation.netstandard.opc.ua.configuration.1.5.374.78.nupkg.sha512",
"/root/.nuget/packages/opcfoundation.netstandard.opc.ua.core/1.5.374.78/opcfoundation.netstandard.opc.ua.core.1.5.374.78.nupkg.sha512",
"/root/.nuget/packages/opcfoundation.netstandard.opc.ua.security.certificates/1.5.374.78/opcfoundation.netstandard.opc.ua.security.certificates.1.5.374.78.nupkg.sha512",
"/root/.nuget/packages/system.formats.asn1/8.0.1/system.formats.asn1.8.0.1.nupkg.sha512",
"/root/.nuget/packages/system.security.cryptography.cng/5.0.0/system.security.cryptography.cng.5.0.0.nupkg.sha512"
"/home/pacer/.nuget/packages/microsoft.extensions.dependencyinjection.abstractions/8.0.1/microsoft.extensions.dependencyinjection.abstractions.8.0.1.nupkg.sha512",
"/home/pacer/.nuget/packages/microsoft.extensions.logging.abstractions/8.0.1/microsoft.extensions.logging.abstractions.8.0.1.nupkg.sha512",
"/home/pacer/.nuget/packages/newtonsoft.json/13.0.3/newtonsoft.json.13.0.3.nupkg.sha512",
"/home/pacer/.nuget/packages/npgsql/8.0.4/npgsql.8.0.4.nupkg.sha512",
"/home/pacer/.nuget/packages/opcfoundation.netstandard.opc.ua.client/1.5.374.78/opcfoundation.netstandard.opc.ua.client.1.5.374.78.nupkg.sha512",
"/home/pacer/.nuget/packages/opcfoundation.netstandard.opc.ua.configuration/1.5.374.78/opcfoundation.netstandard.opc.ua.configuration.1.5.374.78.nupkg.sha512",
"/home/pacer/.nuget/packages/opcfoundation.netstandard.opc.ua.core/1.5.374.78/opcfoundation.netstandard.opc.ua.core.1.5.374.78.nupkg.sha512",
"/home/pacer/.nuget/packages/opcfoundation.netstandard.opc.ua.security.certificates/1.5.374.78/opcfoundation.netstandard.opc.ua.security.certificates.1.5.374.78.nupkg.sha512",
"/home/pacer/.nuget/packages/system.formats.asn1/8.0.1/system.formats.asn1.8.0.1.nupkg.sha512",
"/home/pacer/.nuget/packages/system.security.cryptography.cng/5.0.0/system.security.cryptography.cng.5.0.0.nupkg.sha512"
],
"logs": []
}

View File

@@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Mvc;
using Npgsql;
using OpcPks.Core.Data;
using OpcPks.Core.Services;
using OpcPks.Core.Models; // CertRequestModel 참조 추가
using OpcPks.Core.Models;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -14,8 +14,19 @@ namespace OpcPks.Web.Controllers;
[Route("Engineering")]
public class EngineeringController : Controller
{
// 하니웰 데이터 및 PKI 경로 고정
// [현장 설정] 하니웰 데이터 및 PKI 경로 고정
private readonly string _basePath = "/home/pacer/projects/OpcPksPlatform/OpcPks.Core/Data";
// [수정] 의존성 주입을 위한 필드 추가
private readonly CertificateGenerator _certGenerator;
private readonly OpcSessionManager _sessionManager;
// [수정] 생성자를 통해 Program.cs에서 등록된 서비스를 주입받음
public EngineeringController(CertificateGenerator certGenerator, OpcSessionManager sessionManager)
{
_certGenerator = certGenerator;
_sessionManager = sessionManager;
}
#region [ : ]
@@ -72,6 +83,8 @@ public class EngineeringController : Controller
ON CONFLICT (full_node_id) DO NOTHING;";
foreach (var tag in tags) {
if (string.IsNullOrEmpty(tag.NodeId)) continue;
string sContent = tag.NodeId.Contains("s=") ? tag.NodeId.Split("s=")[1] : tag.NodeId;
string[] parts = sContent.Split(':');
string server = parts[0];
@@ -107,15 +120,14 @@ public class EngineeringController : Controller
public async Task<IActionResult> RunCrawler()
{
try {
var sessionManager = new OpcSessionManager();
var session = await sessionManager.GetSessionAsync();
// [수정] new 생성 대신 주입된 _sessionManager 사용
// GetSessionAsync에 필요한 인자(IP) 전달 (기본값 또는 모델 활용)
var session = await _sessionManager.GetSessionAsync("192.168.0.20", new CertRequestModel());
if (session == null || !session.Connected)
return BadRequest(new { message = "하니웰 서버 연결 실패." });
var crawler = new HoneywellCrawler(session);
string csvPath = Path.Combine(_basePath, "Honeywell_FullMap.csv");
await crawler.RunAsync("ns=1;s=$assetmodel", csvPath);
return Ok(new { message = "탐사 및 CSV 생성 완료!" });
}
@@ -142,18 +154,18 @@ public class EngineeringController : Controller
#endregion
#region [ : ]
#region [ : ]
[HttpGet("CertManager")]
public IActionResult CertManager()
{
string pfxPath = Path.Combine(_basePath, "pki/own/private/OpcTestClient.pfx");
// [약속] 통일된 경로 확인 (CertificateGenerator 내부 경로와 일치시킴)
string pfxPath = Path.Combine(_basePath, "own/private/OpcPksClient.pfx");
// 파일 존재 여부를 ViewData에 담아 보냅니다.
ViewBag.IsCertExists = System.IO.File.Exists(pfxPath);
ViewBag.SuccessMsg = TempData["Success"];
ViewBag.ErrorMsg = TempData["Error"];
return View(new CertRequestModel());
}
@@ -162,26 +174,38 @@ public class EngineeringController : Controller
{
try
{
var generator = new CertificateGenerator(_basePath);
var success = await generator.CreateHoneywellCertificateAsync(model);
// [수정] 직접 new 생성하던 코드를 지우고 주입된 _certGenerator 사용
// 이제 이 호출은 내부적으로 하니웰 서버와 3회 밀당을 수행합니다.
var success = await _certGenerator.CreateHoneywellCertificateAsync(model);
if (success) {
TempData["Success"] = "하니웰 FTE 대응 인증서 생성 및 백업되었습니다.";
TempData["Success"] = "인증서 생성 및 하니웰 서버 최종 수용 확인 완료!";
return RedirectToAction("CertManager");
}
TempData["Error"] = "인증서 생성 중 오류가 발생했습니다.";
TempData["Error"] = "인증서 전송 과정에서 오류가 발생했습니다.";
return RedirectToAction("CertManager");
}
catch (Exception ex)
{
TempData["Error"] = ex.Message;
Console.WriteLine($"[FATAL_CERT] {DateTime.Now}: {ex.Message}");
TempData["Error"] = $"시스템 오류: {ex.Message}";
return RedirectToAction("CertManager");
}
}
#endregion
public class SearchRequest { public string TagTerm { get; set; } public List<string> Suffixes { get; set; } }
public class TagRegistrationRequest { public string TagName { get; set; } public string NodeId { get; set; } public string DataType { get; set; } }
public class SearchRequest
{
public string TagTerm { get; set; } = string.Empty;
public List<string> Suffixes { get; set; } = new();
}
public class TagRegistrationRequest
{
public string TagName { get; set; } = string.Empty;
public string NodeId { get; set; } = string.Empty;
public string DataType { get; set; } = "Double";
}
}

View File

@@ -1,27 +1,58 @@
using OpcPks.Core.Services;
using OpcPks.Core.Models;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// -----------------------------------------------------------
// 1. 공통 경로 설정
// -----------------------------------------------------------
string baseDataPath = "/home/pacer/projects/OpcPksPlatform/OpcPks.Core/Data";
// -----------------------------------------------------------
// 2. 서비스 등록 (Dependency Injection)
// -----------------------------------------------------------
builder.Services.AddControllersWithViews();
// [수정] OpcSessionManager를 먼저 등록해야 Generator에서 가져다 쓸 수 있습니다.
builder.Services.AddSingleton<OpcSessionManager>();
// [수정] 팩토리 메서드를 사용하여 OpcSessionManager를 주입(Injection)합니다.
builder.Services.AddSingleton<CertificateGenerator>(sp =>
{
var sessionManager = sp.GetRequiredService<OpcSessionManager>();
return new CertificateGenerator(baseDataPath, sessionManager);
});
var app = builder.Build();
// Configure the HTTP request pipeline.
// -----------------------------------------------------------
// 3. 실행 환경 설정
// -----------------------------------------------------------
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
//app.UseHttpsRedirection();
// -----------------------------------------------------------
// 4. 미들웨어 및 라우팅 설정
// -----------------------------------------------------------
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
// 컨트롤러 루트 매핑
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();
// [이미지: ASP.NET Core Dependency Injection Container flow]
// -----------------------------------------------------------
// 5. 서버 가동
// -----------------------------------------------------------
Console.WriteLine("🚀 OpcPksPlatform 서버 대기 중... (http://0.0.0.0:5000)");
Console.WriteLine("💡 사용자가 '인증서 생성' 버튼을 누를 때까지 대기합니다.");
app.Run();

View File

@@ -1,89 +1,117 @@
@model OpcPks.Core.Models.CertRequestModel
<div class="container mt-4">
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h4>🛡️ 하니웰 Experion 전용 인증서 생성기</h4>
<div class="card shadow border-0">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
<h4 class="mb-0">🛡️ Honeywell Experion Certificate Manager</h4>
<span class="badge bg-success">v2.2 (Safe SMB Transfer)</span>
</div>
<div class="card-body">
<form asp-action="GenerateCertificate" method="post">
<form asp-action="GenerateCertificate" method="post" id="certForm">
<div class="card bg-light mb-4 border-info">
<div class="card-body">
<h5 class="card-title text-info"><i class="bi bi-shield-lock-fill"></i> Windows Administrator Credentials</h5>
<p class="small text-muted">인증서 파일을 하니웰 서버의 Trusted 폴더로 안전하게 전송하기 위한 관리자 권한이 필요합니다.</p>
<div class="row">
<div class="col-md-6 mb-2">
<label asp-for="AdminId" class="form-label small fw-bold">Admin ID</label>
<input asp-for="AdminId" class="form-control" placeholder="Administrator" required />
<span asp-validation-for="AdminId" class="text-danger small"></span>
</div>
<div class="col-md-6 mb-2">
<label asp-for="AdminPassword" class="form-label small fw-bold">Password</label>
<input asp-for="AdminPassword" type="password" class="form-control" placeholder="********" required />
<span asp-validation-for="AdminPassword" class="text-danger small"></span>
</div>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-md-6">
<label class="form-label fw-bold">시스템 구성</label>
<label asp-for="IsRedundant" class="form-label fw-bold">시스템 구성 (FTE)</label>
<select asp-for="IsRedundant" class="form-select" id="systemType">
<option value="false">Standalone (단일 서버)</option>
<option value="true">Redundant (이중화 서버)</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label fw-bold">어플리케이션 이름</label>
<input asp-for="ApplicationName" class="form-control" placeholder="OpcPksClient" />
<label asp-for="ApplicationName" class="form-label fw-bold">어플리케이션 이름</label>
<input asp-for="ApplicationName" class="form-control" readonly value="OpcPksClient" />
</div>
</div>
<hr />
<div class="row">
<div class="col-md-6">
<div class="p-3 border rounded bg-white shadow-sm">
<h5 class="text-primary border-bottom pb-2">Primary Server</h5>
<div class="mb-2">
<label class="small fw-bold">Host Name</label>
<input asp-for="PrimaryHostName" class="form-control form-control-sm" />
</div>
<div class="mb-2">
<label class="small text-warning fw-bold">FTE Yellow (A)</label>
<input asp-for="PrimaryIpA" class="form-control form-control-sm" placeholder="192.168.0.x" />
</div>
<div>
<label class="small text-success fw-bold">FTE Green (B)</label>
<input asp-for="PrimaryIpB" class="form-control form-control-sm" placeholder="192.168.1.x" />
</div>
</div>
</div>
<h5 class="text-secondary"><span class="badge bg-info">Primary</span> 서버 정보</h5>
<div class="row mb-3">
<div class="col-md-4">
<label class="form-label">Host Name</label>
<input asp-for="PrimaryHostName" class="form-control" placeholder="예: HONPKS" />
</div>
<div class="col-md-4">
<label class="form-label text-warning">IP Address (Yellow)</label>
<input asp-for="PrimaryIpA" class="form-control" placeholder="192.168.0.20" />
</div>
<div class="col-md-4">
<label class="form-label text-success">IP Address (Green)</label>
<input asp-for="PrimaryIpB" class="form-control" placeholder="192.168.1.20" />
<div class="col-md-6">
<div class="p-3 border rounded bg-light shadow-sm" id="secondaryContainer">
<h5 class="text-secondary border-bottom pb-2" id="secondaryTitle">Secondary Server</h5>
<div class="mb-2">
<label class="small fw-bold">Host Name</label>
<input asp-for="SecondaryHostName" class="form-control form-control-sm sec-input" />
</div>
<div class="mb-2">
<label class="small text-warning fw-bold">FTE Yellow (A)</label>
<input asp-for="SecondaryIpA" class="form-control form-control-sm sec-input" />
</div>
<div>
<label class="small text-success fw-bold">FTE Green (B)</label>
<input asp-for="SecondaryIpB" class="form-control form-control-sm sec-input" />
</div>
</div>
</div>
</div>
<h5 class="text-secondary mt-4"><span class="badge bg-secondary" id="secondaryBadge">Secondary</span> 서버 정보</h5>
<div class="row mb-3" id="secondaryFields">
<div class="col-md-4">
<label class="form-label">Host Name</label>
<input asp-for="SecondaryHostName" class="form-control sec-input" placeholder="예: HONPKS" />
</div>
<div class="col-md-4">
<label class="form-label text-warning">IP Address (Yellow)</label>
<input asp-for="SecondaryIpA" class="form-control sec-input" placeholder="192.168.0.21" />
</div>
<div class="col-md-4">
<label class="form-label text-success">IP Address (Green)</label>
<input asp-for="SecondaryIpB" class="form-control sec-input" placeholder="192.168.1.21" />
</div>
</div>
<div class="card bg-light border-warning mt-5">
<div class="card-body">
@if (ViewBag.IsCertExists == true)
{
<div class="alert alert-warning d-flex align-items-center">
<span class="fs-4 me-3">⚠️</span>
<div>
<strong>기존 인증서가 이미 존재합니다!</strong><br />
새로 생성 시 기존 서버와의 통신 신뢰관계(Trust)가 깨질 수 있습니다.
<div class="card mt-4 border-primary">
<div class="card-body bg-light">
<div class="alert alert-info mb-0">
<h6 class="fw-bold"><i class="bi bi-info-circle-fill"></i> 하니웰 인증서 안전 전송 안내</h6>
<p class="small mb-2">이 작업은 하니웰 <code>HSCSERVER_Servicehost.exe</code> 프로세스를 <strong>종료하지 않습니다.</strong></p>
<ul class="small mb-0">
<li>인증서 파일(.der)만 원격 서버의 Trusted 저장소로 복사됩니다.</li>
<li>실제 적용은 추후 엔지니어가 서버를 수동으로 재시작할 때 반영됩니다.</li>
<li>현재 운영 중인 실시간 데이터 및 Station 연결에 영향을 주지 않습니다.</li>
</ul>
</div>
<div class="mt-3">
@if (ViewBag.IsCertExists == true)
{
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="chkUnlock" onchange="toggleCertBtn()">
<label class="form-check-label text-primary fw-bold" for="chkUnlock">
기존 인증서 갱신 및 파일 전송에 동의합니다.
</label>
</div>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="chkUnlock" onchange="toggleCertBtn()">
<label class="form-check-label text-danger fw-bold" for="chkUnlock">
[위험 인지] 기존 인증서를 무시하고 새로 생성하는 것에 동의합니다.
</label>
</div>
<button type="submit" id="btnGenerate" class="btn btn-danger btn-lg w-100" disabled>
🔒 인증서가 이미 존재하여 잠겨있습니다
</button>
}
else
{
<button type="submit" id="btnGenerate" class="btn btn-primary btn-lg w-100">
🚀 인증서 생성 및 자동 적용 시작
</button>
}
<button type="button" id="btnGenerate" class="btn btn-secondary btn-lg w-100 shadow-sm" onclick="confirmTransfer()" disabled>
<i class="bi bi-lock-fill"></i> 기존 인증서 존재 (보호됨)
</button>
}
else
{
<button type="button" id="btnGenerate" class="btn btn-primary btn-lg w-100 shadow-sm" onclick="confirmTransfer()">
<i class="bi bi-send-check-fill"></i> 인증서 생성 및 원격 전송 시작
</button>
}
</div>
</div>
</div>
</form>
@@ -92,37 +120,48 @@
</div>
@section Scripts {
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
$(document).ready(function () {
// 1. 이중화 선택 로직
$('#systemType').change(function () {
const isRedundant = $(this).val() === 'true';
$('.sec-input').prop('disabled', !isRedundant);
if(isRedundant) {
$('#secondaryBadge').removeClass('bg-secondary').addClass('bg-info');
$('#secondaryContainer').removeClass('bg-light').addClass('bg-white');
$('#secondaryTitle').removeClass('text-secondary').addClass('text-primary');
} else {
$('#secondaryBadge').removeClass('bg-info').addClass('bg-secondary');
$('#secondaryContainer').addClass('bg-light').removeClass('bg-white');
$('#secondaryTitle').addClass('text-secondary').removeClass('text-primary');
}
});
$('#systemType').trigger('change');
}).trigger('change');
});
// 2. 버튼 잠금 해제 로직
function toggleCertBtn() {
const isChecked = document.getElementById('chkUnlock').checked;
const btn = document.getElementById('btnGenerate');
const isChecked = $('#chkUnlock').is(':checked');
const btn = $('#btnGenerate');
if(isChecked) {
btn.disabled = false;
btn.innerText = "🔥 인증서 새로 생성 (강제 실행)";
btn.classList.replace('btn-danger', 'btn-warning');
btn.prop('disabled', false).html('<i class="bi bi-arrow-repeat"></i> 인증서 갱신 및 전송 실행').removeClass('btn-secondary').addClass('btn-info text-white');
} else {
btn.disabled = true;
btn.innerText = "🔒 인증서가 이미 존재하여 잠겨있습니다";
btn.classList.replace('btn-warning', 'btn-danger');
btn.prop('disabled', true).html('<i class="bi bi-lock-fill"></i> 기존 인증서 존재 (보호됨)').removeClass('btn-info text-white').addClass('btn-secondary');
}
}
function confirmTransfer() {
Swal.fire({
title: '인증서 전송',
text: "하니웰 서버의 파일만 교체됩니다. 서비스 연결은 끊기지 않습니다. 진행하시겠습니까?",
icon: 'question',
showCancelButton: true,
confirmButtonColor: '#0d6efd',
cancelButtonColor: '#6c757d',
confirmButtonText: '네, 전송합니다',
cancelButtonText: '취소'
}).then((result) => {
if (result.isConfirmed) {
$('#certForm').submit();
}
});
}
</script>
}

View File

@@ -13,10 +13,10 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("OpcPks.Web")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+4e006a5a5f65f597fafaa3777b8a1073944eada2")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+4ea351946aa5ffb76c8c693b768cb7f460f0cb79")]
[assembly: System.Reflection.AssemblyProductAttribute("OpcPks.Web")]
[assembly: System.Reflection.AssemblyTitleAttribute("OpcPks.Web")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
// MSBuild WriteCodeFragment 클래스에서 생성되었습니다.
// Generated by the MSBuild WriteCodeFragment class.

View File

@@ -1 +1 @@
e1494e87c92c732759b5281464380da6aa825fc12aba342324181898b9f0d33d
b82f0dd43a91c4b442cef90caf9521112866289d267678a4f9dd1c8eb3205e82

View File

@@ -1 +1 @@
da88b9ec6f30d3a67ed38dde28b0fbcd513cedae3a9742b74a5f06636cf1a15e
211409f9c66f4d86deef10e146a286bf43dcff595588425d60ad90ee2a7fd32f

View File

@@ -1 +1 @@
6467bb07a49b01c95068173444a1866c77670de41eed0f2a920478da9d4c65bc
ef34cdd3c41169ab6b411993059073056d84145d36a38b4aea950172aff7d741

View File

@@ -10,11 +10,11 @@
"projectUniqueName": "/home/pacer/projects/OpcPksPlatform/OpcPks.Core/OpcPks.Core.csproj",
"projectName": "OpcPks.Core",
"projectPath": "/home/pacer/projects/OpcPksPlatform/OpcPks.Core/OpcPks.Core.csproj",
"packagesPath": "/root/.nuget/packages/",
"packagesPath": "/home/pacer/.nuget/packages/",
"outputPath": "/home/pacer/projects/OpcPksPlatform/OpcPks.Core/obj/",
"projectStyle": "PackageReference",
"configFilePaths": [
"/root/.nuget/NuGet/NuGet.Config"
"/home/pacer/.nuget/NuGet/NuGet.Config"
],
"originalTargetFrameworks": [
"net8.0"
@@ -73,11 +73,11 @@
"projectUniqueName": "/home/pacer/projects/OpcPksPlatform/OpcPks.Web/OpcPks.Web.csproj",
"projectName": "OpcPks.Web",
"projectPath": "/home/pacer/projects/OpcPksPlatform/OpcPks.Web/OpcPks.Web.csproj",
"packagesPath": "/root/.nuget/packages/",
"packagesPath": "/home/pacer/.nuget/packages/",
"outputPath": "/home/pacer/projects/OpcPksPlatform/OpcPks.Web/obj/",
"projectStyle": "PackageReference",
"configFilePaths": [
"/root/.nuget/NuGet/NuGet.Config"
"/home/pacer/.nuget/NuGet/NuGet.Config"
],
"originalTargetFrameworks": [
"net8.0"

View File

@@ -4,12 +4,12 @@
<RestoreSuccess Condition=" '$(RestoreSuccess)' == '' ">True</RestoreSuccess>
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">/root/.nuget/packages/</NuGetPackageRoot>
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">/root/.nuget/packages/</NuGetPackageFolders>
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">/home/pacer/.nuget/packages/</NuGetPackageRoot>
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">/home/pacer/.nuget/packages/</NuGetPackageFolders>
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">6.8.1</NuGetToolVersion>
</PropertyGroup>
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<SourceRoot Include="/root/.nuget/packages/" />
<SourceRoot Include="/home/pacer/.nuget/packages/" />
</ItemGroup>
</Project>

View File

@@ -574,7 +574,7 @@
]
},
"packageFolders": {
"/root/.nuget/packages/": {}
"/home/pacer/.nuget/packages/": {}
},
"project": {
"version": "1.0.0",
@@ -582,11 +582,11 @@
"projectUniqueName": "/home/pacer/projects/OpcPksPlatform/OpcPks.Web/OpcPks.Web.csproj",
"projectName": "OpcPks.Web",
"projectPath": "/home/pacer/projects/OpcPksPlatform/OpcPks.Web/OpcPks.Web.csproj",
"packagesPath": "/root/.nuget/packages/",
"packagesPath": "/home/pacer/.nuget/packages/",
"outputPath": "/home/pacer/projects/OpcPksPlatform/OpcPks.Web/obj/",
"projectStyle": "PackageReference",
"configFilePaths": [
"/root/.nuget/NuGet/NuGet.Config"
"/home/pacer/.nuget/NuGet/NuGet.Config"
],
"originalTargetFrameworks": [
"net8.0"

View File

@@ -1,19 +1,19 @@
{
"version": 2,
"dgSpecHash": "1ODJAJibZeCWjDB9k93/7u2tzIkxUKCpt4LpJwtoyk9SPN+V9Z00ioG5BCZSRPJv0f/i3y9/JoZYPrLxXjCawQ==",
"dgSpecHash": "Lhk78ivUpJSYUca9AozBeOWL1MSXwG/J03crkR8ohPXr0Jr13fI6SjcPbbT0IMW8j7gHacAchV4I/6FtcYipBA==",
"success": true,
"projectFilePath": "/home/pacer/projects/OpcPksPlatform/OpcPks.Web/OpcPks.Web.csproj",
"expectedPackageFiles": [
"/root/.nuget/packages/microsoft.extensions.dependencyinjection.abstractions/8.0.1/microsoft.extensions.dependencyinjection.abstractions.8.0.1.nupkg.sha512",
"/root/.nuget/packages/microsoft.extensions.logging.abstractions/8.0.1/microsoft.extensions.logging.abstractions.8.0.1.nupkg.sha512",
"/root/.nuget/packages/newtonsoft.json/13.0.3/newtonsoft.json.13.0.3.nupkg.sha512",
"/root/.nuget/packages/npgsql/8.0.4/npgsql.8.0.4.nupkg.sha512",
"/root/.nuget/packages/opcfoundation.netstandard.opc.ua.client/1.5.374.78/opcfoundation.netstandard.opc.ua.client.1.5.374.78.nupkg.sha512",
"/root/.nuget/packages/opcfoundation.netstandard.opc.ua.configuration/1.5.374.78/opcfoundation.netstandard.opc.ua.configuration.1.5.374.78.nupkg.sha512",
"/root/.nuget/packages/opcfoundation.netstandard.opc.ua.core/1.5.374.78/opcfoundation.netstandard.opc.ua.core.1.5.374.78.nupkg.sha512",
"/root/.nuget/packages/opcfoundation.netstandard.opc.ua.security.certificates/1.5.374.78/opcfoundation.netstandard.opc.ua.security.certificates.1.5.374.78.nupkg.sha512",
"/root/.nuget/packages/system.formats.asn1/8.0.1/system.formats.asn1.8.0.1.nupkg.sha512",
"/root/.nuget/packages/system.security.cryptography.cng/5.0.0/system.security.cryptography.cng.5.0.0.nupkg.sha512"
"/home/pacer/.nuget/packages/microsoft.extensions.dependencyinjection.abstractions/8.0.1/microsoft.extensions.dependencyinjection.abstractions.8.0.1.nupkg.sha512",
"/home/pacer/.nuget/packages/microsoft.extensions.logging.abstractions/8.0.1/microsoft.extensions.logging.abstractions.8.0.1.nupkg.sha512",
"/home/pacer/.nuget/packages/newtonsoft.json/13.0.3/newtonsoft.json.13.0.3.nupkg.sha512",
"/home/pacer/.nuget/packages/npgsql/8.0.4/npgsql.8.0.4.nupkg.sha512",
"/home/pacer/.nuget/packages/opcfoundation.netstandard.opc.ua.client/1.5.374.78/opcfoundation.netstandard.opc.ua.client.1.5.374.78.nupkg.sha512",
"/home/pacer/.nuget/packages/opcfoundation.netstandard.opc.ua.configuration/1.5.374.78/opcfoundation.netstandard.opc.ua.configuration.1.5.374.78.nupkg.sha512",
"/home/pacer/.nuget/packages/opcfoundation.netstandard.opc.ua.core/1.5.374.78/opcfoundation.netstandard.opc.ua.core.1.5.374.78.nupkg.sha512",
"/home/pacer/.nuget/packages/opcfoundation.netstandard.opc.ua.security.certificates/1.5.374.78/opcfoundation.netstandard.opc.ua.security.certificates.1.5.374.78.nupkg.sha512",
"/home/pacer/.nuget/packages/system.formats.asn1/8.0.1/system.formats.asn1.8.0.1.nupkg.sha512",
"/home/pacer/.nuget/packages/system.security.cryptography.cng/5.0.0/system.security.cryptography.cng.5.0.0.nupkg.sha512"
],
"logs": []
}

View File

@@ -0,0 +1,83 @@
using Microsoft.AspNetCore.Mvc;
using OpcUaManager.Models;
using OpcUaManager.Services;
namespace OpcUaManager.Controllers;
[ApiController]
[Route("api/cert")]
[Produces("application/json")]
public class CertController : ControllerBase
{
private readonly CertService _certService;
private readonly ILogger<CertController> _logger;
public CertController(CertService certService, ILogger<CertController> logger)
{
_certService = certService;
_logger = logger;
}
/// <summary>
/// X.509 클라이언트 인증서를 생성하고 pki/own/certs/ 에 PFX로 저장합니다.
/// </summary>
/// <remarks>
/// 원본 Program.cs 의 인증서 체계(pki/ 폴더, Exportable|MachineKeySet 플래그)를 유지합니다.
///
/// Sample request:
///
/// POST /api/cert/generate
/// {
/// "clientHostName": "dbsvr",
/// "applicationName": "OpcTestClient",
/// "serverHostName": "opc-server-01",
/// "serverIp": "192.168.0.20",
/// "pfxPassword": "",
/// "validDays": 365
/// }
/// </remarks>
[HttpPost("generate")]
[ProducesResponseType(typeof(CertCreateResult), 200)]
[ProducesResponseType(typeof(ApiError), 400)]
public async Task<IActionResult> Generate([FromBody] CertCreateRequest req)
{
if (string.IsNullOrWhiteSpace(req.ClientHostName) ||
string.IsNullOrWhiteSpace(req.ApplicationName))
return BadRequest(new ApiError
{
Error = "필수값 누락",
Detail = "ClientHostName, ApplicationName 은 필수입니다."
});
_logger.LogInformation("인증서 생성 요청: {Host}/{App}", req.ClientHostName, req.ApplicationName);
var result = await _certService.GenerateAsync(req);
if (!result.Success)
return BadRequest(new ApiError { Error = "인증서 생성 실패", Detail = result.Message });
return Ok(result);
}
/// <summary>pki/own/certs/ 폴더의 PFX 파일 목록을 반환합니다.</summary>
[HttpGet("list")]
[ProducesResponseType(typeof(IEnumerable<string>), 200)]
public IActionResult List()
=> Ok(_certService.ListCertificates());
/// <summary>PFX 파일을 다운로드합니다.</summary>
[HttpGet("download/{fileName}")]
public IActionResult Download(string fileName)
{
// 경로 traversal 방지
if (fileName.Contains("..") || fileName.Contains('/') || fileName.Contains('\\'))
return BadRequest(new ApiError { Error = "잘못된 파일 이름" });
string path = Path.Combine("pki", "own", "certs", fileName);
if (!System.IO.File.Exists(path))
return NotFound(new ApiError { Error = "파일 없음", Detail = path });
byte[] bytes = System.IO.File.ReadAllBytes(path);
return File(bytes, "application/x-pkcs12", fileName);
}
}

View File

@@ -0,0 +1,75 @@
using Microsoft.AspNetCore.Mvc;
using OpcUaManager.Models;
using OpcUaManager.Services;
namespace OpcUaManager.Controllers;
[ApiController]
[Route("api/crawler")]
[Produces("application/json")]
public class CrawlerController : ControllerBase
{
private readonly OpcCrawlerService _crawlerSvc;
private readonly OpcSessionService _sessionSvc;
private readonly ILogger<CrawlerController> _logger;
public CrawlerController(
OpcCrawlerService crawlerSvc,
OpcSessionService sessionSvc,
ILogger<CrawlerController> logger)
{
_crawlerSvc = crawlerSvc;
_sessionSvc = sessionSvc;
_logger = logger;
}
/// <summary>
/// 지정한 시작 노드부터 OPC UA 노드 트리를 재귀 탐색합니다.
/// </summary>
/// <remarks>
/// Sample request:
///
/// POST /api/crawler/start
/// {
/// "startNodeId": "ns=1;s=$assetmodel",
/// "maxDepth": 5
/// }
///
/// 탐색 결과는 응답 JSON 과 함께 서버 로컬에 Honeywell_FullMap.csv 로 저장됩니다.
/// </remarks>
[HttpPost("start")]
[ProducesResponseType(typeof(CrawlResult), 200)]
[ProducesResponseType(typeof(ApiError), 400)]
public async Task<IActionResult> Start([FromBody] CrawlRequest req)
{
if (!_sessionSvc.IsConnected)
return BadRequest(new ApiError
{
Error = "세션 없음",
Detail = "먼저 /api/session/connect 로 OPC 서버에 연결하세요."
});
_logger.LogInformation("Crawler 시작: {NodeId} (depth={Depth})",
req.StartNodeId, req.MaxDepth);
// 대규모 탐사는 시간이 오래 걸릴 수 있으므로 타임아웃을 늘려줍니다
HttpContext.RequestAborted.ThrowIfCancellationRequested();
var result = await _crawlerSvc.CrawlAsync(req);
return result.Success ? Ok(result)
: BadRequest(new ApiError { Error = "탐사 실패", Detail = result.Message });
}
/// <summary>마지막 탐사로 생성된 CSV 파일을 다운로드합니다.</summary>
[HttpGet("csv")]
public IActionResult DownloadCsv()
{
string path = Path.GetFullPath("Honeywell_FullMap.csv");
if (!System.IO.File.Exists(path))
return NotFound(new ApiError { Error = "CSV 없음", Detail = "탐사를 먼저 실행하세요." });
byte[] bytes = System.IO.File.ReadAllBytes(path);
return File(bytes, "text/csv", "Honeywell_FullMap.csv");
}
}

View File

@@ -0,0 +1,98 @@
using Microsoft.AspNetCore.Mvc;
using OpcUaManager.Models;
using OpcUaManager.Services;
namespace OpcUaManager.Controllers;
[ApiController]
[Route("api/database")]
[Produces("application/json")]
public class DatabaseController : ControllerBase
{
private readonly DatabaseService _dbSvc;
private readonly ILogger<DatabaseController> _logger;
public DatabaseController(DatabaseService dbSvc, ILogger<DatabaseController> logger)
{
_dbSvc = dbSvc;
_logger = logger;
}
/// <summary>
/// DB 연결을 테스트합니다.
/// </summary>
[HttpPost("test")]
[ProducesResponseType(200)]
[ProducesResponseType(typeof(ApiError), 400)]
public async Task<IActionResult> Test([FromBody] DbWriteRequest req)
{
var (ok, msg) = await _dbSvc.TestConnectionAsync(req);
return ok ? Ok(new { Message = msg })
: BadRequest(new ApiError { Error = "연결 실패", Detail = msg });
}
/// <summary>
/// opc_history 테이블을 없으면 생성합니다.
/// </summary>
[HttpPost("init")]
[ProducesResponseType(200)]
[ProducesResponseType(typeof(ApiError), 400)]
public async Task<IActionResult> Init([FromBody] DbWriteRequest req)
{
var (ok, msg) = await _dbSvc.EnsureTableAsync(req);
return ok ? Ok(new { Message = msg })
: BadRequest(new ApiError { Error = "테이블 초기화 실패", Detail = msg });
}
/// <summary>
/// OPC 태그를 지정 횟수만큼 읽어 DB에 저장합니다 (원본 5회 루프).
/// </summary>
/// <remarks>
/// Sample request:
///
/// POST /api/database/write
/// {
/// "tagNodeId": "ns=1;s=shinam:p-6102.hzset.fieldvalue",
/// "tagName": "p-6102",
/// "count": 5,
/// "intervalMs": 2000,
/// "dbHost": "localhost",
/// "dbName": "opcdb",
/// "dbUser": "postgres",
/// "dbPassword": "postgres"
/// }
/// </remarks>
[HttpPost("write")]
[ProducesResponseType(typeof(DbWriteResult), 200)]
[ProducesResponseType(typeof(ApiError), 400)]
public async Task<IActionResult> Write([FromBody] DbWriteRequest req)
{
if (string.IsNullOrWhiteSpace(req.TagNodeId) || string.IsNullOrWhiteSpace(req.TagName))
return BadRequest(new ApiError
{
Error = "필수값 누락",
Detail = "TagNodeId, TagName 은 필수입니다."
});
req.Count = Math.Clamp(req.Count, 1, 100);
_logger.LogInformation("DB 저장 시작: {Tag} × {Count}회", req.TagName, req.Count);
var result = await _dbSvc.WriteLoopAsync(req);
return result.Success ? Ok(result)
: BadRequest(new ApiError { Error = "저장 실패", Detail = result.Message });
}
/// <summary>
/// opc_history 테이블의 최근 레코드를 조회합니다.
/// </summary>
[HttpPost("query")]
[ProducesResponseType(typeof(DbQueryResult), 200)]
[ProducesResponseType(typeof(ApiError), 400)]
public async Task<IActionResult> Query([FromBody] DbWriteRequest req, [FromQuery] int limit = 100)
{
var result = await _dbSvc.QueryRecentAsync(req, limit);
return result.Success ? Ok(result)
: BadRequest(new ApiError { Error = "조회 실패", Detail = result.Message });
}
}

View File

@@ -0,0 +1,74 @@
using Microsoft.AspNetCore.Mvc;
using OpcUaManager.Models;
using OpcUaManager.Services;
namespace OpcUaManager.Controllers;
[ApiController]
[Route("api/session")]
[Produces("application/json")]
public class SessionController : ControllerBase
{
private readonly OpcSessionService _sessionSvc;
private readonly ILogger<SessionController> _logger;
public SessionController(OpcSessionService sessionSvc, ILogger<SessionController> logger)
{
_sessionSvc = sessionSvc;
_logger = logger;
}
/// <summary>
/// OPC UA 서버에 연결합니다.
/// </summary>
/// <remarks>
/// Sample request:
///
/// POST /api/session/connect
/// {
/// "serverIp": "192.168.0.20",
/// "port": 4840,
/// "userName": "mngr",
/// "password": "mngr",
/// "securityPolicy": "Basic256Sha256",
/// "sessionTimeoutMs": 60000,
/// "pfxPath": "pki/own/certs/OpcTestClient.pfx",
/// "pfxPassword": "",
/// "applicationName": "OpcTestClient",
/// "applicationUri": "urn:dbsvr:OpcTestClient"
/// }
/// </remarks>
[HttpPost("connect")]
[ProducesResponseType(typeof(ConnectResult), 200)]
[ProducesResponseType(typeof(ApiError), 400)]
public async Task<IActionResult> Connect([FromBody] ConnectRequest req)
{
if (string.IsNullOrWhiteSpace(req.ServerIp))
return BadRequest(new ApiError { Error = "ServerIp 는 필수입니다." });
_logger.LogInformation("연결 요청: {Ip}:{Port}", req.ServerIp, req.Port);
var result = await _sessionSvc.ConnectAsync(req);
return result.Success ? Ok(result)
: BadRequest(new ApiError { Error = "연결 실패", Detail = result.Message });
}
/// <summary>현재 OPC UA 세션을 종료합니다.</summary>
[HttpPost("disconnect")]
[ProducesResponseType(200)]
public async Task<IActionResult> Disconnect()
{
await _sessionSvc.DisconnectAsync();
return Ok(new { Message = "세션 종료 완료" });
}
/// <summary>현재 세션 상태를 반환합니다.</summary>
[HttpGet("status")]
[ProducesResponseType(200)]
public IActionResult Status()
=> Ok(new
{
IsConnected = _sessionSvc.IsConnected,
SessionId = _sessionSvc.SessionId
});
}

View File

@@ -0,0 +1,172 @@
namespace OpcUaManager.Models;
// ── 인증서 관련 ────────────────────────────────────────────────
/// <summary>프론트엔드에서 전달받는 인증서 생성 요청</summary>
public class CertCreateRequest
{
/// <summary>내 컴퓨터(클라이언트) 호스트명 e.g. "dbsvr"</summary>
public string ClientHostName { get; set; } = string.Empty;
/// <summary>OPC Application 이름 e.g. "OpcTestClient"</summary>
public string ApplicationName { get; set; } = string.Empty;
/// <summary>상대 OPC 서버 호스트명 (SAN DNS) e.g. "opc-server-01"</summary>
public string ServerHostName { get; set; } = string.Empty;
/// <summary>상대 OPC 서버 IP (SAN IP) e.g. "192.168.0.20"</summary>
public string ServerIp { get; set; } = string.Empty;
/// <summary>PFX 비밀번호 (없으면 빈 문자열)</summary>
public string PfxPassword { get; set; } = string.Empty;
/// <summary>인증서 유효 기간(일)</summary>
public int ValidDays { get; set; } = 365;
}
/// <summary>인증서 생성 결과</summary>
public class CertCreateResult
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
/// <summary>생성된 PFX 파일의 서버 내 절대 경로</summary>
public string PfxPath { get; set; } = string.Empty;
/// <summary>적용된 Application URI</summary>
public string ApplicationUri { get; set; } = string.Empty;
/// <summary>썸프린트(SHA-1 hex)</summary>
public string Thumbprint { get; set; } = string.Empty;
public string SerialNumber { get; set; } = string.Empty;
public string NotBefore { get; set; } = string.Empty;
public string NotAfter { get; set; } = string.Empty;
}
// ── OPC 세션 관련 ────────────────────────────────────────────
public class ConnectRequest
{
public string ServerIp { get; set; } = string.Empty;
public int Port { get; set; } = 4840;
public string UserName { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string SecurityPolicy { get; set; } = "Basic256Sha256";
public int SessionTimeoutMs { get; set; } = 60000;
/// <summary>사용할 PFX 경로 (cert 생성 후 전달)</summary>
public string PfxPath { get; set; } = string.Empty;
public string PfxPassword { get; set; } = string.Empty;
public string ApplicationName { get; set; } = "OpcTestClient";
public string ApplicationUri { get; set; } = string.Empty;
}
public class ConnectResult
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public string SessionId { get; set; } = string.Empty;
public string EndpointUrl { get; set; } = string.Empty;
public string SecurityMode { get; set; } = string.Empty;
}
// ── Crawler 관련 ─────────────────────────────────────────────
public class CrawlRequest
{
public string StartNodeId { get; set; } = "ns=1;s=$assetmodel";
public int MaxDepth { get; set; } = 5;
}
public class TagMaster
{
public string TagName { get; set; } = string.Empty;
public string FullNodeId { get; set; } = string.Empty;
public string NodeClass { get; set; } = string.Empty;
public string DataType { get; set; } = string.Empty;
public int Level { get; set; }
}
public class CrawlResult
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public int TotalNodes { get; set; }
public List<TagMaster> Tags { get; set; } = [];
public string CsvPath { get; set; } = string.Empty;
}
// ── Database 관련 ─────────────────────────────────────────────
public class DbWriteRequest
{
/// <summary>읽을 OPC 태그 Node ID</summary>
public string TagNodeId { get; set; } = string.Empty;
/// <summary>DB에 저장할 태그 이름</summary>
public string TagName { get; set; } = string.Empty;
/// <summary>반복 횟수</summary>
public int Count { get; set; } = 5;
/// <summary>읽기 간격 (ms)</summary>
public int IntervalMs { get; set; } = 2000;
// DB 접속 정보
public string DbHost { get; set; } = "localhost";
public string DbName { get; set; } = "opcdb";
public string DbUser { get; set; } = "postgres";
public string DbPassword { get; set; } = "postgres";
}
public class DbWriteRecord
{
public int Seq { get; set; }
public DateTime Timestamp { get; set; }
public string TagName { get; set; } = string.Empty;
public double Value { get; set; }
public string StatusCode { get; set; } = string.Empty;
public bool DbSaved { get; set; }
}
public class DbWriteResult
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public int SavedCount { get; set; }
public List<DbWriteRecord> Records { get; set; } = [];
}
public class DbQueryResult
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public int TotalCount { get; set; }
public List<OpcHistoryRow> Rows { get; set; } = [];
}
public class OpcHistoryRow
{
public long Id { get; set; }
public string TagName { get; set; } = string.Empty;
public double TagValue { get; set; }
public string StatusCode { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
}
// ── 공통 ─────────────────────────────────────────────────────
public class StatusCodeInfo
{
public string Name { get; set; } = string.Empty;
public string Hex { get; set; } = string.Empty;
public ulong Decimal { get; set; }
public string Description { get; set; } = string.Empty;
}
public class ApiError
{
public string Error { get; set; } = string.Empty;
public string Detail { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>OpcUaManager</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.374.70" />
<PackageReference Include="Npgsql" Version="8.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
</Project>

90
opcUaManager/Program.cs Normal file
View File

@@ -0,0 +1,90 @@
using OpcUaManager.Services;
var builder = WebApplication.CreateBuilder(args);
// ── DI 등록 ───────────────────────────────────────────────────────────
builder.Services.AddSingleton<OpcSessionService>();
builder.Services.AddSingleton<CertService>();
builder.Services.AddSingleton<OpcCrawlerService>();
builder.Services.AddSingleton<DatabaseService>();
builder.Services.AddControllers()
.AddJsonOptions(opt =>
{
opt.JsonSerializerOptions.Converters.Add(
new System.Text.Json.Serialization.JsonStringEnumConverter());
opt.JsonSerializerOptions.PropertyNamingPolicy =
System.Text.Json.JsonNamingPolicy.CamelCase;
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(opt =>
{
opt.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo
{
Title = "OPC UA Manager API",
Version = "v1",
Description = "OPC UA 클라이언트 인증서 생성 · 세션 관리 · 노드 탐색 · DB 저장"
});
});
builder.Services.AddCors(opt =>
{
opt.AddDefaultPolicy(policy =>
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod());
});
// ── 앱 빌드 ───────────────────────────────────────────────────────────
var app = builder.Build();
// ── 정적 파일: wwwroot 경로를 명시적으로 지정 ─────────────────────────
// dotnet run 실행 위치와 무관하게 프로젝트 루트의 wwwroot 를 서빙
var wwwrootPath = Path.Combine(
builder.Environment.ContentRootPath, "wwwroot");
if (!Directory.Exists(wwwrootPath))
{
// dotnet run 이 프로젝트 루트가 아닌 곳에서 실행될 경우 대비
wwwrootPath = Path.Combine(
AppContext.BaseDirectory, "wwwroot");
}
app.Logger.LogInformation("wwwroot 경로: {Path}", wwwrootPath);
app.UseDefaultFiles(new DefaultFilesOptions
{
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(wwwrootPath),
RequestPath = ""
});
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(wwwrootPath),
RequestPath = ""
});
app.UseSwagger();
app.UseSwaggerUI(opt =>
{
opt.SwaggerEndpoint("/swagger/v1/swagger.json", "OPC UA Manager v1");
opt.RoutePrefix = "swagger";
});
app.UseCors();
app.UseAuthorization();
app.MapControllers();
// ── PKI 디렉토리 초기화 ───────────────────────────────────────────────
foreach (var dir in new[]
{
"pki/own/certs", "pki/trusted/certs",
"pki/issuers/certs", "pki/rejected/certs"
})
Directory.CreateDirectory(dir);
app.Logger.LogInformation("=== OPC UA Manager 시작 ===");
app.Logger.LogInformation("Frontend : http://localhost:5000/");
app.Logger.LogInformation("Swagger : http://localhost:5000/swagger");
app.Run();

99
opcUaManager/README.md Normal file
View File

@@ -0,0 +1,99 @@
# OPC UA Manager
원본 `Program.cs` (단일 파일)를 **ASP.NET Core Web API + 프론트엔드** 프로젝트로 분리한 버전입니다.
## 프로젝트 구조
```
OpcUaManager/
├── OpcUaManager.csproj
├── Program.cs ← ASP.NET Core 진입점, DI 등록
├── appsettings.json
├── init_db.sql ← PostgreSQL 테이블 초기화
├── Models/
│ └── Models.cs ← DTO / 도메인 모델 전체
├── Services/
│ ├── CertService.cs ← X.509 인증서 생성 (pki/ 폴더 체계 유지)
│ ├── OpcSessionService.cs ← OPC UA 세션 관리 (Singleton)
│ ├── OpcCrawlerService.cs ← HoneywellCrawler 로직 → Service 분리
│ └── DatabaseService.cs ← PostgreSQL 저장/조회 (SaveToDatabase 패턴 유지)
├── Controllers/
│ ├── CertController.cs ← POST /api/cert/generate
│ ├── SessionController.cs ← POST /api/session/connect|disconnect
│ ├── CrawlerController.cs ← POST /api/crawler/start
│ └── DatabaseController.cs ← POST /api/database/write|query|test
└── wwwroot/
└── index.html ← 프론트엔드 (단일 HTML, 백엔드 API 연결)
```
## 원본 코드와의 대응 관계
| 원본 코드 위치 | 분리된 위치 |
|---|---|
| `Directory.CreateDirectory("pki/...")` | `CertService.EnsurePkiDirectories()` |
| `new X509Certificate2(pfxPath, ...)` | `CertService.LoadPfx()` |
| `ApplicationConfiguration { ... }` | `OpcSessionService.ConnectAsync()` |
| `DiscoveryClient → Session.Create` | `OpcSessionService.ConnectAsync()` |
| `HoneywellCrawler.BrowseRecursiveAsync` | `OpcCrawlerService.CrawlAsync()` |
| `HoneywellCrawler.SaveToCsv` | `OpcCrawlerService.SaveToCsvAsync()` |
| `SaveToDatabase(tagName, val, status)` | `DatabaseService.SaveToDatabaseAsync()` |
| `for (int i = 0; i < 5; i++) { ... }` | `DatabaseService.WriteLoopAsync()` |
**원본 인증서 체계 유지 항목:**
- `pki/own/certs/`, `pki/trusted/certs/`, `pki/issuers/certs/`, `pki/rejected/certs/` 폴더 구조
- `X509KeyStorageFlags.Exportable | MachineKeySet`
- `AutoAcceptUntrustedCertificates = true`, `AddAppCertToTrustedStore = true`
- OPC UA SAN(Subject Alternative Name) 에 `ApplicationUri` 포함 (필수 요구사항)
## 실행 방법
### 1. PostgreSQL 초기화
```bash
createdb opcdb
psql -U postgres -d opcdb -f init_db.sql
```
### 2. 백엔드 실행
```bash
cd OpcUaManager
dotnet restore
dotnet run
# → http://localhost:5000
# → Swagger: http://localhost:5000/swagger
```
### 3. 프론트엔드 접속
브라우저에서 `http://localhost:5000` 접속
(또는 `wwwroot/index.html` 을 별도 Live Server로 열고 API URL을 `http://localhost:5000` 으로 설정)
## API 엔드포인트 요약
```
POST /api/cert/generate 인증서 생성
GET /api/cert/list PFX 목록 조회
GET /api/cert/download/{name} PFX 다운로드
POST /api/session/connect OPC 연결
POST /api/session/disconnect OPC 연결 해제
GET /api/session/status 세션 상태
POST /api/crawler/start 노드 탐사 시작
GET /api/crawler/csv CSV 다운로드
POST /api/database/test DB 연결 테스트
POST /api/database/init 테이블 초기화
POST /api/database/write OPC 읽기 + DB 저장 (N회)
POST /api/database/query 최근 레코드 조회
```
## 의존 패키지
```xml
OPCFoundation.NetStandard.Opc.Ua.Client 1.5.x
Npgsql 8.0.x
Swashbuckle.AspNetCore 6.6.x
```

View File

@@ -0,0 +1,158 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using OpcUaManager.Models;
namespace OpcUaManager.Services;
/// <summary>
/// OPC UA 클라이언트용 X.509 인증서를 생성하고 PFX로 저장합니다.
/// 원본 Program.cs 의 인증서 체계(pki/ 폴더 구조, X509KeyStorageFlags)를 유지합니다.
/// </summary>
public class CertService
{
private readonly ILogger<CertService> _logger;
private readonly string _pkiRoot;
public CertService(ILogger<CertService> logger, IWebHostEnvironment env)
{
_logger = logger;
_pkiRoot = Path.Combine(env.ContentRootPath, "pki");
}
private void EnsurePkiDirectories()
{
Directory.CreateDirectory(Path.Combine(_pkiRoot, "own", "certs"));
Directory.CreateDirectory(Path.Combine(_pkiRoot, "trusted", "certs"));
Directory.CreateDirectory(Path.Combine(_pkiRoot, "issuers", "certs"));
Directory.CreateDirectory(Path.Combine(_pkiRoot, "rejected", "certs"));
}
public Task<CertCreateResult> GenerateAsync(CertCreateRequest req)
{
try
{
EnsurePkiDirectories();
string appUri = $"urn:{req.ClientHostName}:{req.ApplicationName}";
// ── 1. RSA 키 생성 ────────────────────────────────────────
using var rsa = RSA.Create(2048);
// ── 2. Distinguished Name ──────────────────────────────────
var dn = new X500DistinguishedName(
$"CN={req.ApplicationName}, O={req.ClientHostName}, C=KR");
// ── 3. Certificate Request ─────────────────────────────────
var certReq = new CertificateRequest(
dn, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
// ── 4. Extensions ──────────────────────────────────────────
certReq.CertificateExtensions.Add(
new X509BasicConstraintsExtension(false, false, 0, true));
certReq.CertificateExtensions.Add(
new X509KeyUsageExtension(
X509KeyUsageFlags.DigitalSignature |
X509KeyUsageFlags.NonRepudiation |
X509KeyUsageFlags.KeyEncipherment |
X509KeyUsageFlags.DataEncipherment,
critical: true));
certReq.CertificateExtensions.Add(
new X509EnhancedKeyUsageExtension(
new OidCollection
{
new Oid("1.3.6.1.5.5.7.3.2"), // clientAuth
new Oid("1.3.6.1.5.5.7.3.1") // serverAuth
}, false));
certReq.CertificateExtensions.Add(
new X509SubjectKeyIdentifierExtension(certReq.PublicKey, false));
// ── 5. Subject Alternative Names ──────────────────────────
var sanBuilder = new SubjectAlternativeNameBuilder();
sanBuilder.AddUri(new Uri(appUri));
if (!string.IsNullOrWhiteSpace(req.ServerIp) &&
System.Net.IPAddress.TryParse(req.ServerIp, out var ip))
sanBuilder.AddIpAddress(ip);
if (!string.IsNullOrWhiteSpace(req.ServerHostName))
sanBuilder.AddDnsName(req.ServerHostName);
sanBuilder.AddDnsName(req.ClientHostName);
sanBuilder.AddDnsName("localhost");
certReq.CertificateExtensions.Add(sanBuilder.Build());
// ── 6. 자체 서명 인증서 생성 ──────────────────────────────
var notBefore = DateTimeOffset.UtcNow.AddMinutes(-5);
var notAfter = notBefore.AddDays(req.ValidDays);
// FIX: CreateSelfSigned 는 이미 RSA 키가 연결된 X509Certificate2 를 반환함
// → CopyWithPrivateKey() 를 추가로 호출하면
// "The certificate already has an associated private key" 에러 발생
// → CreateSelfSigned 결과를 직접 Export(Pfx) 하면 개인키 포함됨
using var certWithKey = certReq.CreateSelfSigned(notBefore, notAfter);
// ── 7. PFX 내보내기 ───────────────────────────────────────
string pfxPassword = string.IsNullOrEmpty(req.PfxPassword) ? "" : req.PfxPassword;
// .NET 9 이상: X509CertificateLoader 권장이지만 net8 에서는 Export 사용
byte[] pfxBytes = certWithKey.Export(X509ContentType.Pfx, pfxPassword);
string pfxFileName = $"{req.ApplicationName}.pfx";
string pfxPath = Path.Combine(_pkiRoot, "own", "certs", pfxFileName);
File.WriteAllBytes(pfxPath, pfxBytes);
// ── 8. Trusted 폴더에 DER(공개키) 복사 ───────────────────
byte[] derBytes = certWithKey.Export(X509ContentType.Cert);
string derPath = Path.Combine(_pkiRoot, "trusted", "certs",
$"{req.ApplicationName}[{certWithKey.Thumbprint}].der");
File.WriteAllBytes(derPath, derBytes);
_logger.LogInformation(
"인증서 생성 완료 → {PfxPath} | Thumbprint: {Thumbprint}",
pfxPath, certWithKey.Thumbprint);
return Task.FromResult(new CertCreateResult
{
Success = true,
Message = $"인증서 생성 완료: {pfxFileName}",
PfxPath = pfxPath,
ApplicationUri = appUri,
Thumbprint = certWithKey.Thumbprint ?? string.Empty,
SerialNumber = certWithKey.SerialNumber ?? string.Empty,
NotBefore = notBefore.ToString("yyyy-MM-dd HH:mm:ss UTC"),
NotAfter = notAfter.ToString("yyyy-MM-dd HH:mm:ss UTC"),
});
}
catch (Exception ex)
{
_logger.LogError(ex, "인증서 생성 실패");
return Task.FromResult(new CertCreateResult
{
Success = false,
Message = $"인증서 생성 실패: {ex.Message}"
});
}
}
/// <summary>
/// 기존 PFX를 로드합니다 (원본 코드와 동일한 플래그).
/// </summary>
public X509Certificate2 LoadPfx(string pfxPath, string pfxPassword)
{
return new X509Certificate2(
pfxPath,
pfxPassword,
X509KeyStorageFlags.Exportable | X509KeyStorageFlags.MachineKeySet);
}
public IEnumerable<string> ListCertificates()
{
string dir = Path.Combine(_pkiRoot, "own", "certs");
if (!Directory.Exists(dir)) return [];
return Directory.GetFiles(dir, "*.pfx").Select(Path.GetFileName)!;
}
}

View File

@@ -0,0 +1,223 @@
using Npgsql;
using OpcUaManager.Models;
namespace OpcUaManager.Services;
/// <summary>
/// PostgreSQL opc_history 테이블에 대한 읽기/쓰기를 담당합니다.
/// 원본 SaveToDatabase 함수의 INSERT 패턴을 그대로 유지하며,
/// OPC 태그 읽기(OpcSessionService)와 DB 저장을 조율합니다.
/// </summary>
public class DatabaseService
{
private readonly ILogger<DatabaseService> _logger;
private readonly OpcSessionService _sessionSvc;
public DatabaseService(ILogger<DatabaseService> logger, OpcSessionService sessionSvc)
{
_logger = logger;
_sessionSvc = sessionSvc;
}
// ── 연결 문자열 빌더 ──────────────────────────────────────────────
private static string BuildConnString(DbWriteRequest req)
=> $"Host={req.DbHost};Username={req.DbUser};Password={req.DbPassword};Database={req.DbName}";
// ── DB/테이블 초기화 (최초 한 번) ────────────────────────────────
/// <summary>
/// opc_history 테이블이 없으면 자동 생성합니다.
/// 원본 코드에는 없었지만, 프로젝트화 시 처음 실행 환경을 위해 추가합니다.
/// </summary>
public async Task<(bool Ok, string Msg)> EnsureTableAsync(DbWriteRequest req)
{
const string ddl = """
CREATE TABLE IF NOT EXISTS opc_history (
id BIGSERIAL PRIMARY KEY,
tag_name TEXT NOT NULL,
tag_value DOUBLE PRECISION NOT NULL,
status_code TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
""";
try
{
await using var conn = new NpgsqlConnection(BuildConnString(req));
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(ddl, conn);
await cmd.ExecuteNonQueryAsync();
return (true, "테이블 확인/생성 완료");
}
catch (Exception ex)
{
_logger.LogError(ex, "테이블 초기화 실패");
return (false, ex.Message);
}
}
// ── OPC 읽기 + DB 저장 루프 ──────────────────────────────────────
/// <summary>
/// OPC 노드를 req.Count 회 읽고 각 결과를 DB에 저장합니다.
/// 원본 for 루프 + SaveToDatabase 패턴을 그대로 유지합니다.
/// </summary>
public async Task<DbWriteResult> WriteLoopAsync(DbWriteRequest req)
{
if (!_sessionSvc.IsConnected)
return new DbWriteResult { Success = false, Message = "OPC 세션이 연결되어 있지 않습니다." };
var (tableOk, tableMsg) = await EnsureTableAsync(req);
if (!tableOk)
return new DbWriteResult { Success = false, Message = $"테이블 오류: {tableMsg}" };
var records = new List<DbWriteRecord>();
int saved = 0;
for (int i = 1; i <= req.Count; i++)
{
// ── 1. OPC 태그 읽기 ────────────────────────────────────
double val = 0;
string status = "Unknown";
bool dbSaved = false;
try
{
var (rawVal, sc) = await _sessionSvc.ReadValueAsync(req.TagNodeId);
val = Convert.ToDouble(rawVal);
status = sc;
_logger.LogInformation("[{I}/{N}] {Tag} = {Val} ({Status})",
i, req.Count, req.TagName, val, status);
}
catch (Exception ex)
{
status = $"ReadError: {ex.Message}";
_logger.LogWarning("[{I}/{N}] OPC 읽기 실패: {Msg}", i, req.Count, ex.Message);
}
// ── 2. DB 저장 (원본 SaveToDatabase 로직) ───────────────
try
{
await SaveToDatabaseAsync(req, val, status);
dbSaved = true;
saved++;
_logger.LogInformation("[{I}/{N}] DB 저장 완료.", i, req.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "[{I}/{N}] DB 저장 실패", i, req.Count);
}
records.Add(new DbWriteRecord
{
Seq = i,
Timestamp = DateTime.UtcNow,
TagName = req.TagName,
Value = val,
StatusCode = status,
DbSaved = dbSaved
});
// ── 3. 인터벌 대기 (마지막 회차는 생략) ─────────────────
if (i < req.Count && req.IntervalMs > 0)
await Task.Delay(req.IntervalMs);
}
return new DbWriteResult
{
Success = saved > 0,
Message = $"{saved}/{req.Count}회 저장 완료",
SavedCount = saved,
Records = records
};
}
// ── 원본 SaveToDatabase (INSERT 패턴 동일) ────────────────────────
private async Task SaveToDatabaseAsync(DbWriteRequest req, double val, string status)
{
await using var conn = new NpgsqlConnection(BuildConnString(req));
await conn.OpenAsync();
// 원본 동일 INSERT
const string sql =
"INSERT INTO opc_history (tag_name, tag_value, status_code) " +
"VALUES (@tag, @val, @status)";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("tag", req.TagName);
cmd.Parameters.AddWithValue("val", val);
cmd.Parameters.AddWithValue("status", status);
await cmd.ExecuteNonQueryAsync();
}
// ── DB 조회 ───────────────────────────────────────────────────────
public async Task<DbQueryResult> QueryRecentAsync(DbWriteRequest req, int limit = 100)
{
try
{
await using var conn = new NpgsqlConnection(BuildConnString(req));
await conn.OpenAsync();
string sql = $"""
SELECT id, tag_name, tag_value, status_code, created_at
FROM opc_history
ORDER BY id DESC
LIMIT {limit}
""";
await using var cmd = new NpgsqlCommand(sql, conn);
await using var reader = await cmd.ExecuteReaderAsync();
var rows = new List<OpcHistoryRow>();
while (await reader.ReadAsync())
{
rows.Add(new OpcHistoryRow
{
Id = reader.GetInt64(0),
TagName = reader.GetString(1),
TagValue = reader.GetDouble(2),
StatusCode = reader.GetString(3),
CreatedAt = reader.GetDateTime(4)
});
}
// 전체 카운트
await reader.CloseAsync();
await using var cntCmd = new NpgsqlCommand("SELECT COUNT(*) FROM opc_history", conn);
long total = (long)(await cntCmd.ExecuteScalarAsync() ?? 0L);
return new DbQueryResult
{
Success = true,
TotalCount = (int)total,
Rows = rows
};
}
catch (Exception ex)
{
_logger.LogError(ex, "DB 조회 실패");
return new DbQueryResult { Success = false, Message = ex.Message };
}
}
// ── 연결 테스트 ───────────────────────────────────────────────────
public async Task<(bool Ok, string Msg)> TestConnectionAsync(DbWriteRequest req)
{
try
{
await using var conn = new NpgsqlConnection(BuildConnString(req));
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand("SELECT version()", conn);
var ver = await cmd.ExecuteScalarAsync();
return (true, $"연결 성공: {ver}");
}
catch (Exception ex)
{
return (false, ex.Message);
}
}
}

View File

@@ -0,0 +1,161 @@
using Opc.Ua;
using Opc.Ua.Client;
using OpcUaManager.Models;
namespace OpcUaManager.Services;
/// <summary>
/// 원본 HoneywellCrawler 를 Service 로 분리.
/// v1.5.374.70: BrowseNextAsync 가 튜플 반환이 아닌 BrowseNextResponse 객체 반환으로 변경됨.
/// </summary>
public class OpcCrawlerService
{
private readonly ILogger<OpcCrawlerService> _logger;
private readonly OpcSessionService _sessionSvc;
public OpcCrawlerService(ILogger<OpcCrawlerService> logger, OpcSessionService sessionSvc)
{
_logger = logger;
_sessionSvc = sessionSvc;
}
public async Task<CrawlResult> CrawlAsync(CrawlRequest req)
{
if (!_sessionSvc.IsConnected)
return new CrawlResult { Success = false, Message = "세션이 연결되어 있지 않습니다." };
var session = _sessionSvc.GetRawSession();
if (session == null)
return new CrawlResult { Success = false, Message = "Raw 세션을 가져올 수 없습니다." };
var tags = new List<TagMaster>();
try
{
_logger.LogInformation("저인망 탐사 시작: {NodeId}", req.StartNodeId);
NodeId rootNode = NodeId.Parse(req.StartNodeId);
await BrowseRecursiveAsync(session, rootNode, 0, req.MaxDepth, tags);
string csvPath = await SaveToCsvAsync(tags);
_logger.LogInformation("탐사 완료: {Count}개 노드", tags.Count);
return new CrawlResult
{
Success = true,
Message = $"탐사 완료: {tags.Count}개 노드 발견",
TotalNodes = tags.Count,
Tags = tags,
CsvPath = csvPath
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Crawler 실행 오류");
return new CrawlResult { Success = false, Message = ex.Message, Tags = tags };
}
}
private async Task BrowseRecursiveAsync(
Session session, NodeId nodeId, int level, int maxDepth, List<TagMaster> tags)
{
try
{
// 원본 동일: BrowseAsync 객체 방식
BrowseDescription description = new()
{
NodeId = nodeId,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = (uint)(NodeClass.Variable | NodeClass.Object),
ResultMask = (uint)BrowseResultMask.All
};
BrowseResponse response = await session.BrowseAsync(
null, null, 0, [description], default);
if (response?.Results == null || response.Results.Count == 0) return;
foreach (var result in response.Results)
{
await ProcessReferencesAsync(session, result.References, level, maxDepth, tags);
byte[] cp = result.ContinuationPoint;
while (cp != null && cp.Length > 0)
{
// FIX CS8130/CS1503: BrowseNextAsync 튜플 방식 → BrowseNextResponse 객체 방식
// v1.5.374.70 에서 반환 타입이 Task<BrowseNextResponse> 로 변경됨
BrowseNextResponse nextResponse = await session.BrowseNextAsync(
null,
false,
new ByteStringCollection { cp },
default);
if (nextResponse?.Results != null && nextResponse.Results.Count > 0)
{
var nextResult = nextResponse.Results[0];
await ProcessReferencesAsync(session, nextResult.References, level, maxDepth, tags);
cp = nextResult.ContinuationPoint;
}
else
{
break;
}
}
}
}
catch (Exception ex)
{
// 원본과 동일: 특정 노드 권한 에러 무시
_logger.LogDebug("노드 탐색 건너뜀 [{Level}] {NodeId}: {Msg}", level, nodeId, ex.Message);
}
}
private async Task ProcessReferencesAsync(
Session session,
ReferenceDescriptionCollection? references,
int level,
int maxDepth,
List<TagMaster> tags)
{
if (references == null || references.Count == 0) return;
foreach (var rd in references)
{
NodeId childId = ExpandedNodeId.ToNodeId(rd.NodeId, session.NamespaceUris);
tags.Add(new TagMaster
{
TagName = rd.BrowseName.Name ?? "Unknown",
FullNodeId = childId.ToString(),
NodeClass = rd.NodeClass.ToString(),
Level = level
});
_logger.LogDebug("{Indent}[{Class}] {Name} ({Id})",
new string(' ', level * 2), rd.NodeClass, rd.BrowseName.Name, childId);
if (rd.NodeClass == NodeClass.Object && level < maxDepth)
await BrowseRecursiveAsync(session, childId, level + 1, maxDepth, tags);
}
}
private async Task<string> SaveToCsvAsync(List<TagMaster> tags)
{
string path = Path.GetFullPath("Honeywell_FullMap.csv");
try
{
await using var sw = new StreamWriter(path);
await sw.WriteLineAsync("Level,Class,Name,NodeId");
foreach (var tag in tags)
await sw.WriteLineAsync($"{tag.Level},{tag.NodeClass},{tag.TagName},{tag.FullNodeId}");
_logger.LogInformation("CSV 저장 완료: {Path}", path);
}
catch (Exception ex)
{
_logger.LogError(ex, "CSV 저장 실패");
}
return path;
}
}

View File

@@ -0,0 +1,180 @@
using System.Text;
using Opc.Ua;
using Opc.Ua.Client;
using OpcUaManager.Models;
// Microsoft.AspNetCore.Http.ISession 과 Opc.Ua.Client.ISession 충돌 해소
using OpcUaISession = Opc.Ua.Client.ISession;
// Microsoft.AspNetCore.Http.StatusCodes 와 Opc.Ua.StatusCodes 충돌 해소
using OpcStatusCodes = Opc.Ua.StatusCodes;
namespace OpcUaManager.Services;
/// <summary>
/// OPC UA 세션을 싱글톤으로 관리합니다.
/// v1.5.374.70 API 기준으로 수정되었습니다.
/// </summary>
public class OpcSessionService : IAsyncDisposable
{
private readonly ILogger<OpcSessionService> _logger;
private readonly CertService _certService;
private OpcUaISession? _session;
private ApplicationConfiguration? _appConfig;
private readonly SemaphoreSlim _lock = new(1, 1);
public bool IsConnected => _session?.Connected == true;
public string SessionId => _session?.SessionId?.ToString() ?? string.Empty;
public OpcSessionService(ILogger<OpcSessionService> logger, CertService certService)
{
_logger = logger;
_certService = certService;
}
public async Task<ConnectResult> ConnectAsync(ConnectRequest req)
{
await _lock.WaitAsync();
try
{
if (IsConnected)
await InternalCloseAsync();
string endpointUrl = $"opc.tcp://{req.ServerIp}:{req.Port}";
// ── 1. 클라이언트 인증서 로드 ────────────────────────────
System.Security.Cryptography.X509Certificates.X509Certificate2? clientCert = null;
if (!string.IsNullOrWhiteSpace(req.PfxPath) && File.Exists(req.PfxPath))
clientCert = _certService.LoadPfx(req.PfxPath, req.PfxPassword);
string appUri = string.IsNullOrWhiteSpace(req.ApplicationUri)
? $"urn:{System.Net.Dns.GetHostName()}:{req.ApplicationName}"
: req.ApplicationUri;
// ── 2. ApplicationConfiguration ───────────────────────────
_appConfig = new ApplicationConfiguration
{
ApplicationName = req.ApplicationName,
ApplicationType = ApplicationType.Client,
ApplicationUri = appUri,
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = clientCert != null
? new CertificateIdentifier { Certificate = clientCert }
: new CertificateIdentifier(),
TrustedPeerCertificates = new CertificateTrustList
{ StoreType = "Directory", StorePath = Path.GetFullPath("pki/trusted") },
TrustedIssuerCertificates = new CertificateTrustList
{ StoreType = "Directory", StorePath = Path.GetFullPath("pki/issuers") },
RejectedCertificateStore = new CertificateTrustList
{ StoreType = "Directory", StorePath = Path.GetFullPath("pki/rejected") },
AutoAcceptUntrustedCertificates = true,
AddAppCertToTrustedStore = true
},
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = req.SessionTimeoutMs }
};
// FIX CS1061: ValidateAsync → Validate (v1.5.374.70에서 동기 메서드)
_appConfig.Validate(ApplicationType.Client);
// FIX CS0104: StatusCodes → OpcStatusCodes 별칭 사용
_appConfig.CertificateValidator.CertificateValidation += (_, e) =>
{
if (e.Error.StatusCode != OpcStatusCodes.Good) e.Accept = true;
};
// ── 3. Endpoint Discovery ─────────────────────────────────
_logger.LogInformation("Endpoint Discovery: {Url}", endpointUrl);
var endpointConfig = EndpointConfiguration.Create(_appConfig);
// FIX CS0117: DiscoveryClient.CreateAsync 없음 → 동기 Create 사용
EndpointDescriptionCollection endpoints;
using (var discovery = DiscoveryClient.Create(new Uri(endpointUrl), endpointConfig))
{
endpoints = discovery.GetEndpoints(null);
}
var selected = endpoints
.OrderByDescending(e => e.SecurityLevel)
.FirstOrDefault(e => e.SecurityPolicyUri.Contains(req.SecurityPolicy))
?? endpoints.OrderByDescending(e => e.SecurityLevel).First();
var endpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
// FIX CS1503: UserIdentity(user, byte[]) → UserIdentity(user, string)
// v1.5.374.70 에서 password 파라미터가 string 으로 변경됨
var identity = new UserIdentity(req.UserName, req.Password);
// ── 5. Session.Create ─────────────────────────────────────
#pragma warning disable CS0618
_session = await Session.Create(
_appConfig,
endpoint,
false,
"OpcUaManagerSession",
(uint)req.SessionTimeoutMs,
identity,
null);
#pragma warning restore CS0618
string secMode = selected.SecurityMode.ToString();
_logger.LogInformation("OPC UA 연결 완료 | SessionId={Id} | Security={Sec}",
_session.SessionId, secMode);
return new ConnectResult
{
Success = true,
Message = "연결 성공",
SessionId = _session.SessionId.ToString(),
EndpointUrl = endpointUrl,
SecurityMode = secMode
};
}
catch (Exception ex)
{
_logger.LogError(ex, "OPC UA 연결 실패");
return new ConnectResult { Success = false, Message = ex.Message };
}
finally
{
_lock.Release();
}
}
public async Task<bool> DisconnectAsync()
{
await _lock.WaitAsync();
try { await InternalCloseAsync(); return true; }
finally { _lock.Release(); }
}
private async Task InternalCloseAsync()
{
if (_session != null)
{
try { await _session.CloseAsync(); } catch { /* ignore */ }
_session.Dispose();
_session = null;
}
}
public async Task<(object? Value, string StatusCode)> ReadValueAsync(string nodeId)
{
if (!IsConnected || _session == null)
throw new InvalidOperationException("세션이 연결되어 있지 않습니다.");
var result = await _session.ReadValueAsync(nodeId);
return (result.Value, result.StatusCode.ToString());
}
public Session? GetRawSession() => _session as Session;
public async ValueTask DisposeAsync()
{
await InternalCloseAsync();
_lock.Dispose();
GC.SuppressFinalize(this);
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,266 @@
{
"runtimeTarget": {
"name": ".NETCoreApp,Version=v8.0",
"signature": ""
},
"compilationOptions": {},
"targets": {
".NETCoreApp,Version=v8.0": {
"OpcUaManager/1.0.0": {
"dependencies": {
"Npgsql": "8.0.3",
"OPCFoundation.NetStandard.Opc.Ua.Client": "1.5.374.70",
"Swashbuckle.AspNetCore": "6.6.2"
},
"runtime": {
"OpcUaManager.dll": {}
}
},
"Microsoft.Extensions.ApiDescription.Server/6.0.5": {},
"Microsoft.Extensions.DependencyInjection.Abstractions/8.0.0": {},
"Microsoft.Extensions.Logging.Abstractions/8.0.0": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0"
}
},
"Microsoft.OpenApi/1.6.14": {
"runtime": {
"lib/netstandard2.0/Microsoft.OpenApi.dll": {
"assemblyVersion": "1.6.14.0",
"fileVersion": "1.6.14.0"
}
}
},
"Newtonsoft.Json/13.0.3": {
"runtime": {
"lib/net6.0/Newtonsoft.Json.dll": {
"assemblyVersion": "13.0.0.0",
"fileVersion": "13.0.3.27908"
}
}
},
"Npgsql/8.0.3": {
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
},
"runtime": {
"lib/net8.0/Npgsql.dll": {
"assemblyVersion": "8.0.3.0",
"fileVersion": "8.0.3.0"
}
}
},
"OPCFoundation.NetStandard.Opc.Ua.Client/1.5.374.70": {
"dependencies": {
"OPCFoundation.NetStandard.Opc.Ua.Configuration": "1.5.374.70",
"OPCFoundation.NetStandard.Opc.Ua.Core": "1.5.374.70"
},
"runtime": {
"lib/net8.0/Opc.Ua.Client.dll": {
"assemblyVersion": "1.5.374.0",
"fileVersion": "1.5.374.70"
}
}
},
"OPCFoundation.NetStandard.Opc.Ua.Configuration/1.5.374.70": {
"dependencies": {
"OPCFoundation.NetStandard.Opc.Ua.Core": "1.5.374.70"
},
"runtime": {
"lib/net8.0/Opc.Ua.Configuration.dll": {
"assemblyVersion": "1.5.374.0",
"fileVersion": "1.5.374.70"
}
}
},
"OPCFoundation.NetStandard.Opc.Ua.Core/1.5.374.70": {
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "8.0.0",
"Newtonsoft.Json": "13.0.3",
"OPCFoundation.NetStandard.Opc.Ua.Security.Certificates": "1.5.374.70"
},
"runtime": {
"lib/net8.0/Opc.Ua.Core.dll": {
"assemblyVersion": "1.5.374.0",
"fileVersion": "1.5.374.70"
}
}
},
"OPCFoundation.NetStandard.Opc.Ua.Security.Certificates/1.5.374.70": {
"dependencies": {
"System.Formats.Asn1": "8.0.0",
"System.Security.Cryptography.Cng": "5.0.0"
},
"runtime": {
"lib/net8.0/Opc.Ua.Security.Certificates.dll": {
"assemblyVersion": "1.5.374.0",
"fileVersion": "1.5.374.70"
}
}
},
"Swashbuckle.AspNetCore/6.6.2": {
"dependencies": {
"Microsoft.Extensions.ApiDescription.Server": "6.0.5",
"Swashbuckle.AspNetCore.Swagger": "6.6.2",
"Swashbuckle.AspNetCore.SwaggerGen": "6.6.2",
"Swashbuckle.AspNetCore.SwaggerUI": "6.6.2"
}
},
"Swashbuckle.AspNetCore.Swagger/6.6.2": {
"dependencies": {
"Microsoft.OpenApi": "1.6.14"
},
"runtime": {
"lib/net8.0/Swashbuckle.AspNetCore.Swagger.dll": {
"assemblyVersion": "6.6.2.0",
"fileVersion": "6.6.2.401"
}
}
},
"Swashbuckle.AspNetCore.SwaggerGen/6.6.2": {
"dependencies": {
"Swashbuckle.AspNetCore.Swagger": "6.6.2"
},
"runtime": {
"lib/net8.0/Swashbuckle.AspNetCore.SwaggerGen.dll": {
"assemblyVersion": "6.6.2.0",
"fileVersion": "6.6.2.401"
}
}
},
"Swashbuckle.AspNetCore.SwaggerUI/6.6.2": {
"runtime": {
"lib/net8.0/Swashbuckle.AspNetCore.SwaggerUI.dll": {
"assemblyVersion": "6.6.2.0",
"fileVersion": "6.6.2.401"
}
}
},
"System.Formats.Asn1/8.0.0": {},
"System.Security.Cryptography.Cng/5.0.0": {
"dependencies": {
"System.Formats.Asn1": "8.0.0"
}
}
}
},
"libraries": {
"OpcUaManager/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Microsoft.Extensions.ApiDescription.Server/6.0.5": {
"type": "package",
"serviceable": true,
"sha512": "sha512-Ckb5EDBUNJdFWyajfXzUIMRkhf52fHZOQuuZg/oiu8y7zDCVwD0iHhew6MnThjHmevanpxL3f5ci2TtHQEN6bw==",
"path": "microsoft.extensions.apidescription.server/6.0.5",
"hashPath": "microsoft.extensions.apidescription.server.6.0.5.nupkg.sha512"
},
"Microsoft.Extensions.DependencyInjection.Abstractions/8.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-cjWrLkJXK0rs4zofsK4bSdg+jhDLTaxrkXu4gS6Y7MAlCvRyNNgwY/lJi5RDlQOnSZweHqoyvgvbdvQsRIW+hg==",
"path": "microsoft.extensions.dependencyinjection.abstractions/8.0.0",
"hashPath": "microsoft.extensions.dependencyinjection.abstractions.8.0.0.nupkg.sha512"
},
"Microsoft.Extensions.Logging.Abstractions/8.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==",
"path": "microsoft.extensions.logging.abstractions/8.0.0",
"hashPath": "microsoft.extensions.logging.abstractions.8.0.0.nupkg.sha512"
},
"Microsoft.OpenApi/1.6.14": {
"type": "package",
"serviceable": true,
"sha512": "sha512-tTaBT8qjk3xINfESyOPE2rIellPvB7qpVqiWiyA/lACVvz+xOGiXhFUfohcx82NLbi5avzLW0lx+s6oAqQijfw==",
"path": "microsoft.openapi/1.6.14",
"hashPath": "microsoft.openapi.1.6.14.nupkg.sha512"
},
"Newtonsoft.Json/13.0.3": {
"type": "package",
"serviceable": true,
"sha512": "sha512-HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==",
"path": "newtonsoft.json/13.0.3",
"hashPath": "newtonsoft.json.13.0.3.nupkg.sha512"
},
"Npgsql/8.0.3": {
"type": "package",
"serviceable": true,
"sha512": "sha512-6WEmzsQJCZAlUG1pThKg/RmeF6V+I0DmBBBE/8YzpRtEzhyZzKcK7ulMANDm5CkxrALBEC8H+5plxHWtIL7xnA==",
"path": "npgsql/8.0.3",
"hashPath": "npgsql.8.0.3.nupkg.sha512"
},
"OPCFoundation.NetStandard.Opc.Ua.Client/1.5.374.70": {
"type": "package",
"serviceable": true,
"sha512": "sha512-TiguVbV6kANLKqV8KUPzf89re/ng53ebq3xaLUJJTfg0NAYxZTmUGYGAULR8c/C9aVi6xM5MGAuq+ggf1jEl+A==",
"path": "opcfoundation.netstandard.opc.ua.client/1.5.374.70",
"hashPath": "opcfoundation.netstandard.opc.ua.client.1.5.374.70.nupkg.sha512"
},
"OPCFoundation.NetStandard.Opc.Ua.Configuration/1.5.374.70": {
"type": "package",
"serviceable": true,
"sha512": "sha512-Qai1Ieo+laEO6ARW/YNuAcS1W34KJYqcbg4gUEYMBvHUNvl1ACgp8lYbXkj34vJ9BlqHlT9nYM0W6ioynUbaAw==",
"path": "opcfoundation.netstandard.opc.ua.configuration/1.5.374.70",
"hashPath": "opcfoundation.netstandard.opc.ua.configuration.1.5.374.70.nupkg.sha512"
},
"OPCFoundation.NetStandard.Opc.Ua.Core/1.5.374.70": {
"type": "package",
"serviceable": true,
"sha512": "sha512-zOrqHMkHm5Pf9oFbRgscr5smU0Jx54l3ARGJcBQugbb2r+O53PPR8C/y1a+8FtI4Lx/feDB2qgjwZ8xqzSfuXQ==",
"path": "opcfoundation.netstandard.opc.ua.core/1.5.374.70",
"hashPath": "opcfoundation.netstandard.opc.ua.core.1.5.374.70.nupkg.sha512"
},
"OPCFoundation.NetStandard.Opc.Ua.Security.Certificates/1.5.374.70": {
"type": "package",
"serviceable": true,
"sha512": "sha512-hBCW2MAOX+BJmzVOekOCBLiSYNGz5hDJM/JEvGyaCqJ4dTv3D5/Fgi4y9NgquuY2pOd9bd9mOWjGgdW9ZQdJOg==",
"path": "opcfoundation.netstandard.opc.ua.security.certificates/1.5.374.70",
"hashPath": "opcfoundation.netstandard.opc.ua.security.certificates.1.5.374.70.nupkg.sha512"
},
"Swashbuckle.AspNetCore/6.6.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-+NB4UYVYN6AhDSjW0IJAd1AGD8V33gemFNLPaxKTtPkHB+HaKAKf9MGAEUPivEWvqeQfcKIw8lJaHq6LHljRuw==",
"path": "swashbuckle.aspnetcore/6.6.2",
"hashPath": "swashbuckle.aspnetcore.6.6.2.nupkg.sha512"
},
"Swashbuckle.AspNetCore.Swagger/6.6.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-ovgPTSYX83UrQUWiS5vzDcJ8TEX1MAxBgDFMK45rC24MorHEPQlZAHlaXj/yth4Zf6xcktpUgTEBvffRQVwDKA==",
"path": "swashbuckle.aspnetcore.swagger/6.6.2",
"hashPath": "swashbuckle.aspnetcore.swagger.6.6.2.nupkg.sha512"
},
"Swashbuckle.AspNetCore.SwaggerGen/6.6.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-zv4ikn4AT1VYuOsDCpktLq4QDq08e7Utzbir86M5/ZkRaLXbCPF11E1/vTmOiDzRTl0zTZINQU2qLKwTcHgfrA==",
"path": "swashbuckle.aspnetcore.swaggergen/6.6.2",
"hashPath": "swashbuckle.aspnetcore.swaggergen.6.6.2.nupkg.sha512"
},
"Swashbuckle.AspNetCore.SwaggerUI/6.6.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-mBBb+/8Hm2Q3Wygag+hu2jj69tZW5psuv0vMRXY07Wy+Rrj40vRP8ZTbKBhs91r45/HXT4aY4z0iSBYx1h6JvA==",
"path": "swashbuckle.aspnetcore.swaggerui/6.6.2",
"hashPath": "swashbuckle.aspnetcore.swaggerui.6.6.2.nupkg.sha512"
},
"System.Formats.Asn1/8.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-AJukBuLoe3QeAF+mfaRKQb2dgyrvt340iMBHYv+VdBzCUM06IxGlvl0o/uPOS7lHnXPN6u8fFRHSHudx5aTi8w==",
"path": "system.formats.asn1/8.0.0",
"hashPath": "system.formats.asn1.8.0.0.nupkg.sha512"
},
"System.Security.Cryptography.Cng/5.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-jIMXsKn94T9JY7PvPq/tMfqa6GAaHpElRDpmG+SuL+D3+sTw2M8VhnibKnN8Tq+4JqbPJ/f+BwtLeDMEnzAvRg==",
"path": "system.security.cryptography.cng/5.0.0",
"hashPath": "system.security.cryptography.cng.5.0.0.nupkg.sha512"
}
}
}

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,19 @@
{
"runtimeOptions": {
"tfm": "net8.0",
"frameworks": [
{
"name": "Microsoft.NETCore.App",
"version": "8.0.0"
},
{
"name": "Microsoft.AspNetCore.App",
"version": "8.0.0"
}
],
"configProperties": {
"System.GC.Server": true,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}

Some files were not shown because too many files have changed in this diff Show More