Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/PARAMETERIZED_WSB_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,3 +309,10 @@ param(
창이 `-NoExit`로 뜬 뒤 DNS 프로브 → ps1 다운로드 → dot-source 하므로 실패는 항상 창에 남는다. 사이트
사전선택은 dot-source 인자(`. $p '__SPORK_SITE_IDS__'`)로 전달. 자산은 build.yml/build.cs가 게시하며
해당 자산 포함 릴리스 게시 후 활성화.
- (ZScaler #292 구현, 2026-07-06) SSL 검사(ZScaler 등) 환경에서 샌드박스 HTTPS가 차단되는 문제 대응.
옵션 토글(`PreferenceSettings.EnableZScalerRootCertPropagation`, 기본 꺼짐)이 켜지면 호스트가
`LocalMachine\Root`에서 Subject에 "Zscaler"가 포함된 루트 인증서를 추출해 staging `App\zscaler\zscaler.pem`
으로 전달하고, `SandboxBootstrap`이 게스트 진입 시 `CurrentUser\Root` 등록 + `NODE_EXTRA_CA_CERTS`(User) +
git `http.sslBackend=schannel`을 구성한다. 호스트에 해당 인증서가 없으면 켜져 있어도 자동으로 건너뛴다.
**이 옵션은 모드 1(TableCloth 빌드 샌드박스) 전용이며, 무설치(모드 2/§0.5) 식탁보에서는 지원되지 않는다**
(무설치 정적 자산은 ASCII 전용·호스트 인증서 접근 경로가 없으므로).
94 changes: 94 additions & 0 deletions src/Spork.Sandbox/SandboxBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Net;
using System.Net.NetworkInformation;
using System.Runtime.InteropServices;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -67,6 +68,7 @@ public Task RunAsync(CancellationToken cancellationToken = default)

TryPlaceCertPair();
TryCopyNpkiMountToCanonicalPath();
TryPropagateZScalerRootCert();
return Task.CompletedTask;
}

Expand Down Expand Up @@ -270,6 +272,98 @@ private static void CopyDirectoryRecursive(string source, string destination)
}
}

/// <summary>
/// 호스트가 <c>App\zscaler\zscaler.pem</c>으로 스테이징한 ZScaler 루트 인증서를 게스트로 전파한다(이슈 #292).
/// ZScaler 같은 SSL 검사(엔드포인트 통제) 환경에서는 호스트가 신뢰하는 ZScaler 루트 인증서가
/// 샌드박스로 자동 전파되지 않아 게스트 HTTPS 연결이 모두 차단된다. 옵션이 켜져 있고 PEM이 존재하면:
/// <list type="number">
/// <item>사용자 신뢰 루트(<c>CurrentUser\Root</c>)에 인증서를 등록해 브라우저·SChannel HTTPS를 복원하고,</item>
/// <item>Node.js를 위해 <c>NODE_EXTRA_CA_CERTS</c>(User 스코프)를 PEM 경로로 설정하고,</item>
/// <item>git이 SChannel(윈도우 인증서 저장소)을 쓰도록 <c>http.sslBackend=schannel</c>을 구성한다.</item>
/// </list>
/// 옵션이 꺼져 있거나 PEM이 없으면 아무 것도 하지 않는다(베스트에포트).
/// </summary>
private void TryPropagateZScalerRootCert()
{
try
{
if (!(LoadSporkAnswers()?.EnableZScalerRootCertPropagation ?? false))
return;

var pemPath = Path.Combine(AppContext.BaseDirectory, "zscaler", "zscaler.pem");
if (!File.Exists(pemPath))
{
_logger.LogDebug("ZScaler propagation enabled but no staged PEM found — skipping.");
return;
}

var certificates = new X509Certificate2Collection();
certificates.ImportFromPemFile(pemPath);
if (certificates.Count < 1)
{
_logger.LogDebug("Staged ZScaler PEM contained no certificates — skipping.");
return;
}

try
{
// 1) 현재 사용자 신뢰 루트에 등록(상승 불필요, SChannel/브라우저 대응).
// Store.Add는 인증서를 저장소로 복제하므로, 아래 finally에서 원본을 해제해도 안전하다.
using (var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser))
{
store.Open(OpenFlags.ReadWrite);
store.AddRange(certificates);
}

// 2) Node.js: 저장소 무관하게 PEM을 직접 지정(User 스코프 + 현재 세션 즉시 반영).
Environment.SetEnvironmentVariable("NODE_EXTRA_CA_CERTS", pemPath, EnvironmentVariableTarget.User);
Environment.SetEnvironmentVariable("NODE_EXTRA_CA_CERTS", pemPath, EnvironmentVariableTarget.Process);

// 3) git이 SChannel(윈도우 인증서 저장소)을 사용하도록 구성(선택).
TryConfigureGitSChannel();

_logger.LogDebug("Propagated {count} ZScaler root certificate(s) into the sandbox.", certificates.Count);
}
finally
{
// X509Certificate2는 IDisposable(키 핸들 보유)이므로 등록 후 원본을 해제한다.
foreach (var cert in certificates)
cert.Dispose();
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to propagate ZScaler root certificate into sandbox.");
}
}

private void TryConfigureGitSChannel()
{
try
{
var startInfo = new ProcessStartInfo("git.exe", "config --global http.sslBackend schannel")
{
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};

using var process = Process.Start(startInfo);
if (process is not null && !process.WaitForExit(TimeSpan.FromSeconds(5)))
{
// 5초 내 종료하지 않으면(예: git 자격 증명 프롬프트로 멈춤) 좀비로 남지 않도록 강제 종료한다.
try { process.Kill(entireProcessTree: true); }
catch { /* 이미 종료됐거나 접근 불가 — 무시 */ }
}
}
catch (Exception ex)
{
// git이 없을 수 있다(샌드박스 기본 이미지). 인증서 등록만으로도 대부분의 HTTPS가 복원되므로 무해.
_logger.LogDebug(ex, "Skipped configuring git SChannel backend (git may be unavailable).");
}
}

// --- 이슈 #246: 호스트 라이트/다크 테마를 시작 시점에 게스트로 반영 ---
private const int HwndBroadcast = 0xFFFF;
private const uint WmSettingChange = 0x001A;
Expand Down
72 changes: 72 additions & 0 deletions src/TableCloth.App/Components/Implementations/SandboxBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Threading;
Expand Down Expand Up @@ -100,8 +101,12 @@
IdleAutoLogoutMinutes = preferences.IdleAutoLogoutMinutes,
// 공용 DNS 폴백 옵션(이슈 #285). 게스트는 probe-then-fallback으로만 적용.
EnableSandboxPublicDnsFallback = preferences.EnableSandboxPublicDnsFallback,
// ZScaler 루트 인증서 전파 옵션(이슈 #292). 켜져 있고 호스트에 ZScaler 루트 인증서가 있을 때만
// App\zscaler\zscaler.pem이 스테이징되며, 게스트가 이를 사용자 신뢰 루트에 등록한다.
EnableZScalerRootCertPropagation = preferences.EnableZScalerRootCertPropagation,
};
await StageCertPairAsync(appDirectory, tableClothConfiguration.CertPair, sporkAnswers, cancellationToken).ConfigureAwait(false);
StageZScalerRootCerts(appDirectory, preferences.EnableZScalerRootCertPropagation);

var batchFileContent = GenerateSandboxStartupScript(tableClothConfiguration);
var batchFilePath = Path.Combine(appDirectory, "StartupScript.cmd");
Expand Down Expand Up @@ -285,6 +290,73 @@
sporkAnswers.CertSubjectNameForNpkiApp = certPair.SubjectNameForNpkiApp;
}

/// <summary>
/// ZScaler 같은 SSL 검사 환경에서, 호스트의 <c>LocalMachine\Root</c> 저장소에 설치된 ZScaler 루트
/// 인증서(Subject에 "Zscaler" 포함)를 찾아 PEM으로 staging의 <c>App\zscaler\zscaler.pem</c>에 떨어뜨린다.
/// App 폴더가 그대로 샌드박스 <c>Desktop\App</c>으로 노출되므로 추가 마운트 없이 Spork가
/// <c>AppContext.BaseDirectory\zscaler</c>에서 읽어 게스트 사용자 신뢰 루트에 등록한다(이슈 #292).
/// 옵션이 꺼져 있거나 호스트에 해당 인증서가 없으면 아무 것도 하지 않는다(베스트에포트).
/// </summary>
private static void StageZScalerRootCerts(string appDirectory, bool enabled)
{
if (!enabled)
return;

try
{
// X509Certificate2는 IDisposable(키 핸들 보유)이므로, 매칭되지 않은 인증서는 즉시,
// 매칭된 인증서는 PEM 추출 후 finally에서 정리한다.
var matchingCerts = new List<X509Certificate2>();
try
{
using (var store = new X509Store(StoreName.Root, StoreLocation.LocalMachine))
{
store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
foreach (var cert in store.Certificates)
{
if (cert.Subject.IndexOf("Zscaler", StringComparison.OrdinalIgnoreCase) >= 0)
matchingCerts.Add(cert);
else
cert.Dispose();
}
}

var zscalerStagingDirectoryPath = Path.Combine(appDirectory, "zscaler");
Comment on lines +300 to +324
if (matchingCerts.Count < 1)
{
// 호스트에 ZScaler 루트 인증서가 없으면 이전 세션의 잔여 PEM이 남지 않도록 staging을 정리한다.
if (Directory.Exists(zscalerStagingDirectoryPath))
Directory.Delete(zscalerStagingDirectoryPath, true);
return;
}

if (Directory.Exists(zscalerStagingDirectoryPath))
Directory.Delete(zscalerStagingDirectoryPath, true);
Directory.CreateDirectory(zscalerStagingDirectoryPath);

var builder = new StringBuilder();
foreach (var cert in matchingCerts)
builder.AppendLine(cert.ExportCertificatePem());

// PEM은 ASCII(base64)만 담기므로 ASCII로 기록한다.
File.WriteAllText(
Path.Combine(zscalerStagingDirectoryPath, "zscaler.pem"),
builder.ToString(),
Encoding.ASCII);
}
finally
{
foreach (var cert in matchingCerts)
cert.Dispose();
}
}
catch
{
// 인증서 추출 실패는 치명적이지 않다. 옵션이 켜져 있어도 인터넷 연결 복원만 실패할 뿐,
// 나머지 샌드박스 부팅은 정상 진행되어야 하므로 조용히 넘어간다.
}
}

private string GenerateSandboxStartupScript(TableClothConfiguration tableClothConfiguration)
{
ArgumentNullException.ThrowIfNull(tableClothConfiguration);
Expand Down Expand Up @@ -439,7 +511,7 @@

// single-file 게시는 진입 어셈블리의 Location이 빈 문자열로 보인다.
// 이 경우 런타임은 exe 내부에 묶여 있으므로 추가 마운트 불필요.
var entryAssemblyLocation = typeof(SandboxBuilder).Assembly.Location;

Check warning on line 514 in src/TableCloth.App/Components/Implementations/SandboxBuilder.cs

View workflow job for this annotation

GitHub Actions / build (Debug, arm64)

'System.Reflection.Assembly.Location.get' always returns an empty string for assemblies embedded in a single-file app. If the path to the app directory is needed, consider calling 'System.AppContext.BaseDirectory'.

Check warning on line 514 in src/TableCloth.App/Components/Implementations/SandboxBuilder.cs

View workflow job for this annotation

GitHub Actions / build (Release, x64)

'System.Reflection.Assembly.Location.get' always returns an empty string for assemblies embedded in a single-file app. If the path to the app directory is needed, consider calling 'System.AppContext.BaseDirectory'.

Check warning on line 514 in src/TableCloth.App/Components/Implementations/SandboxBuilder.cs

View workflow job for this annotation

GitHub Actions / build (Debug, x64)

'System.Reflection.Assembly.Location.get' always returns an empty string for assemblies embedded in a single-file app. If the path to the app directory is needed, consider calling 'System.AppContext.BaseDirectory'.

Check warning on line 514 in src/TableCloth.App/Components/Implementations/SandboxBuilder.cs

View workflow job for this annotation

GitHub Actions / build (Release, arm64)

'System.Reflection.Assembly.Location.get' always returns an empty string for assemblies embedded in a single-file app. If the path to the app directory is needed, consider calling 'System.AppContext.BaseDirectory'.
if (string.IsNullOrEmpty(entryAssemblyLocation))
return false;

Expand Down
64 changes: 53 additions & 11 deletions src/TableCloth.App/Dialogs/OptionsWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -130,19 +130,61 @@

<!-- 호환성 (워크어라운드) -->
<TabItem Header="{x:Static res:UIStringResources.Options_Section_Compatibility}">
<StackPanel Margin="12">
<TextBlock Margin="0 0 0 8" TextWrapping="Wrap"
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>

<TextBlock Grid.Row="0" Margin="0 0 0 8" TextWrapping="Wrap"
Foreground="{DynamicResource ControlSecondaryForeground}"
Text="{x:Static res:UIStringResources.Options_Section_Compatibility_Description}" />
<CheckBox Margin="0 4" Content="{x:Static res:UIStringResources.Option_EnableSandboxGpuAccelerationCheckbox}" IsChecked="{Binding EnableSandboxGpuAcceleration}" />
<TextBlock Margin="22 2 0 0" TextWrapping="Wrap" FontSize="11"
Foreground="{DynamicResource ControlSecondaryForeground}"
Text="{x:Static res:UIStringResources.Option_EnableSandboxGpuAccelerationCheckbox_Description}" />
<CheckBox Margin="0 12 0 4" Content="{x:Static res:UIStringResources.Option_EnableSandboxPublicDnsFallbackCheckbox}" IsChecked="{Binding EnableSandboxPublicDnsFallback}" />
<TextBlock Margin="22 2 0 0" TextWrapping="Wrap" FontSize="11"
Foreground="{DynamicResource ControlSecondaryForeground}"
Text="{x:Static res:UIStringResources.Option_EnableSandboxPublicDnsFallbackCheckbox_Description}" />
</StackPanel>

<!-- 설정 항목의 이름·설명(한/영)을 검색해 필터링한다. 항목이 늘어나도 원하는 설정을 빠르게 찾게 한다.
워터마크는 카탈로그 검색(CatalogPage)과 동일한 CueBannerBrush 관용구를 사용한다(빈 값일 때만 표시). -->
<TextBox Grid.Row="1" Margin="0 0 0 8" VerticalContentAlignment="Center"
Text="{Binding CompatibilityOptionSearchText, UpdateSourceTrigger=PropertyChanged}">
<TextBox.Style>
<Style TargetType="TextBox" xmlns:sys="clr-namespace:System;assembly=mscorlib">
<Style.Resources>
<VisualBrush x:Key="CueBannerBrush" AlignmentX="Left" AlignmentY="Center" Stretch="None">
<VisualBrush.Visual>
<Label Content="{x:Static res:UIStringResources.Option_Compatibility_SearchPlaceholder}" Foreground="{DynamicResource ControlSecondaryForeground}" />
</VisualBrush.Visual>
</VisualBrush>
</Style.Resources>
<Style.Triggers>
<Trigger Property="Text" Value="{x:Static sys:String.Empty}">
<Setter Property="Background" Value="{StaticResource CueBannerBrush}" />
</Trigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>

<!-- 체크박스 목록은 수직 전용 스크롤로 감싼다(가로 스크롤 비활성 + 설명 줄바꿈). 항목이 늘어나도 창 높이를 넘지 않는다. -->
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<Grid>
<ItemsControl ItemsSource="{Binding CompatibilityOptions}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type vm:CompatibilityOptionItem}">
<StackPanel Margin="0 0 0 12">
<CheckBox Content="{Binding Title}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
<TextBlock Margin="22 2 0 0" TextWrapping="Wrap" FontSize="11"
Foreground="{DynamicResource ControlSecondaryForeground}"
Text="{Binding Description}" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Margin="2 4 0 0" TextWrapping="Wrap"
Foreground="{DynamicResource ControlSecondaryForeground}"
Text="{x:Static res:UIStringResources.Option_Compatibility_NoSearchResults}"
Visibility="{Binding HasNoCompatibilityMatches, Converter={StaticResource BooleanToVisibilityConverter}}" />
</Grid>
</ScrollViewer>
</Grid>
</TabItem>

<!-- 진단 -->
Expand Down
Loading
Loading