삽질하다 도저히 문제 파악이 안돼서 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

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;
}
}
}