1
0
mirror of https://github.com/fadden/6502bench.git synced 2024-12-02 13:51:36 +00:00
6502bench/SourceGen/Sandbox/DomainManager.cs
Andy McFadden 195c93a94a Reboot sandbox when required
Another chapter in the never-ending AppDomain security saga.

If a computer goes to sleep while SourceGen is running with a project
open, life gets confusing when the system wakes up.  The keep-alive
timer fires and a ping is sent to the remote AppDomain, successfully.
At the same time, the lease expires on the remote side, and the objects
are discarded (apparently without bothering to query the ILease object).
This failure mode is 100% repeatable.

Since we can't prevent sandbox objects from disappearing, we have to
detect and recover from the problem.  Fortunately we don't keep any
necessary state on the plugin side, so we can just tear the whole
thing down and recreate it.

The various methods in ScriptManager now do a "health check" before
making calls into the plugin AppDomain.  If the ping attempt fails,
the AppDomain is "rebooted" by destroying it and creating a new one,
reloading all plugins that were in there before.  The plugin binaries
*should* still be in the PluginDllCache directory since the ping failure
was due to objects being discarded, not AppDomain shutdown, and Windows
doesn't let you mess with files that hold executable code.

A new "reboot security sandbox" option has been added to the DEBUG
menu to facilitate testing.

The PluginManager's Ping() method gets called more often, but not to
the extent that performance will be affected.

This change also adds a finalizer to DisasmProject, since we're relying
on it to shut down the ScriptManager, and it's relying on callers to
invoke its cleanup function.  The finalizer throws an assertion if the
cleanup function doesn't get called.

(Issue #82)
2020-07-19 13:20:18 -07:00

257 lines
11 KiB
C#

/*
* Copyright 2019 faddenSoft
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.Remoting.Lifetime;
using System.Security;
using System.Security.Permissions;
using System.Timers;
using PluginCommon;
namespace SourceGen.Sandbox {
/// <summary>
/// This is a host-side object that manages the plugin AppDomain.
/// </summary>
//[SecurityPermission(SecurityAction.LinkDemand, ControlAppDomain = true, Infrastructure = true)]
public class DomainManager : IDisposable {
/// <summary>
/// For IDisposable.
/// </summary>
private bool mDisposed = false;
/// <summary>
/// AppDomain handle.
/// </summary>
private AppDomain mAppDomain;
/// <summary>
/// Reference to the remote PluginManager object.
/// </summary>
private Sponsor<PluginManager> mPluginManager;
/// <summary>
/// Hack to keep the sandbox from disappearing.
/// </summary>
private Timer mKeepAliveTimer;
/// <summary>
/// Access the remote PluginManager object.
/// </summary>
public PluginManager PluginMgr {
get {
//Debug.Assert(mPluginManager.CheckLease());
return mPluginManager.Instance;
}
}
/// <summary>
/// App domain ID, or -1 if not available.
/// </summary>
public int Id { get { return mAppDomain != null ? mAppDomain.Id : -1; } }
public DomainManager(bool useKeepAlive) {
// Sometimes the sandbox AppDomain can't call back into the main AppDomain to
// get a lease renewal, and the PluginManager object gets collected. See
// https://stackoverflow.com/q/52230527/294248 for details.
//
// The idea is to keep tickling renew-on-call, so that the plugin side never
// has to request renewal. This is ugly but seems to work.
//
// The timer event runs on a pool thread, and calls across domains seem to stay
// on the same thread, so the remote Ping() method must be prepared to be called
// on an arbitrary thread.
if (useKeepAlive) {
Debug.WriteLine("Setting keep-alive timer...");
mKeepAliveTimer = new Timer(60 * 1000);
mKeepAliveTimer.Elapsed += (source, e) => {
// I don't know if there's a shutdown race. The dispose code stops the timer
// before clearing the other fields, but I don't know if the Stop() code
// waits for the currently-executing timer event to finish. So wrap
// everything in try/catch.
try {
int result = mPluginManager.Instance.Ping(1000);
Debug.WriteLine("KeepAlive tid=" +
System.Threading.Thread.CurrentThread.ManagedThreadId +
" result=" + result);
} catch (Exception ex) {
Debug.WriteLine("Keep-alive timer failed: " + ex.Message);
}
};
mKeepAliveTimer.AutoReset = true;
mKeepAliveTimer.Enabled = true;
}
}
/// <summary>
/// Creates a new AppDomain. If our plugin is just executing
/// pre-compiled code we can lock the permissions down, but if
/// it needs to dynamically compile code we need to open things up.
/// </summary>
/// <param name="appDomainName">The "friendly" name.</param>
/// <param name="appBaseBath">Directory to use for ApplicationBase.</param>
public void CreateDomain(string appDomainName, string appBaseBath) {
// This doesn't seem to affect Sponsor. Doing this over in the PluginManager
// does have the desired effect, but requires unrestricted security.
//LifetimeServices.LeaseTime = TimeSpan.FromSeconds(5);
//LifetimeServices.LeaseManagerPollTime = TimeSpan.FromSeconds(3);
//LifetimeServices.RenewOnCallTime = TimeSpan.FromSeconds(2);
//LifetimeServices.SponsorshipTimeout = TimeSpan.FromSeconds(1);
if (mAppDomain != null) {
throw new Exception("Domain already created");
}
PermissionSet permSet;
// Start with everything disabled.
permSet = new PermissionSet(PermissionState.None);
//permSet = new PermissionSet(PermissionState.Unrestricted);
// Allow code execution.
permSet.AddPermission(new SecurityPermission(
SecurityPermissionFlag.Execution));
// This appears to be necessary to allow the lease renewal to work. Without
// this the lease silently fails to renew.
permSet.AddPermission(new SecurityPermission(
SecurityPermissionFlag.Infrastructure));
// Allow changes to Remoting stuff. Without this, we can't
// register our ISponsor.
permSet.AddPermission(new SecurityPermission(
SecurityPermissionFlag.RemotingConfiguration));
// Allow read-only file access, but only in the plugin directory.
// This is necessary to allow PluginLoader to load the assembly.
FileIOPermission fp = new FileIOPermission(
FileIOPermissionAccess.Read | FileIOPermissionAccess.PathDiscovery,
appBaseBath);
permSet.AddPermission(fp);
// TODO(maybe): it looks like this would allow us to mark the PluginCommon dll as
// trusted, so we wouldn't have to give the above permissions to everything.
// That seems to require a cryptographic pair and some other voodoo.
//StrongName fullTrustAssembly =
// typeof(PluginManager).Assembly.Evidence.GetHostEvidence<StrongName>();
// Configure the AppDomain. Setting the ApplicationBase directory away from
// the main app location is apparently very important, as it mitigates the
// risk of certain exploits from untrusted plugin code.
AppDomainSetup adSetup = new AppDomainSetup();
adSetup.ApplicationBase = appBaseBath;
// Create the AppDomain.
mAppDomain = AppDomain.CreateDomain(appDomainName, null, adSetup, permSet);
Debug.WriteLine("Created AppDomain '" + appDomainName + "', id=" + mAppDomain.Id);
//Debug.WriteLine("Loading '" + typeof(PluginManager).Assembly.FullName + "' / '" +
// typeof(PluginManager).FullName + "'");
// Create a PluginManager in the remote AppDomain. The local
// object is actually a proxy.
PluginManager pm = (PluginManager)mAppDomain.CreateInstanceAndUnwrap(
typeof(PluginManager).Assembly.FullName,
typeof(PluginManager).FullName);
// Wrap it so it doesn't disappear on us.
mPluginManager = new Sponsor<PluginManager>(pm);
Debug.WriteLine("IsTransparentProxy: " +
System.Runtime.Remoting.RemotingServices.IsTransparentProxy(pm));
}
/// <summary>
/// Destroy the AppDomain.
/// </summary>
private void DestroyDomain(bool disposing) {
Debug.WriteLine("Unloading AppDomain '" + mAppDomain.FriendlyName +
"', id=" + mAppDomain.Id + ", disposing=" + disposing);
if (mKeepAliveTimer != null) {
mKeepAliveTimer.Stop();
mKeepAliveTimer.Dispose();
mKeepAliveTimer = null;
}
if (mPluginManager != null) {
mPluginManager.Dispose();
mPluginManager = null;
}
if (mAppDomain != null) {
// We can't simply invoke AppDomain.Unload() from a finalizer. The unload is
// handled by a thread that won't run at the same time as the finalizer thread,
// so if we got here through finalization we will deadlock. Fortunately the
// runtime sees the situation and throws an exception out of Unload().
//
// If we don't have a finalizer, and we forget to make an explicit cleanup
// call, the AppDomain will stick around and keep the DLL files locked, which
// could be annoying if the user is trying to iterate on extension script
// development.
//
// So we use a workaround from https://stackoverflow.com/q/4064749/294248
// and invoke it asynchronously.
if (disposing) {
AppDomain.Unload(mAppDomain);
} else {
new Action<AppDomain>(AppDomain.Unload).BeginInvoke(mAppDomain, null, null);
}
mAppDomain = null;
}
}
/// <summary>
/// Finalizer. Required for IDisposable.
/// </summary>
~DomainManager() {
Debug.WriteLine("WARNING: DomainManager finalizer running (id=" +
(mAppDomain != null ? mAppDomain.Id.ToString() : "--") + ")");
Dispose(false);
}
/// <summary>
/// Generic IDisposable implementation.
/// </summary>
public void Dispose() {
// Dispose of unmanaged resources (i.e. the AppDomain).
Dispose(true);
// Suppress finalization.
GC.SuppressFinalize(this);
}
/// <summary>
/// Destroys the AppDomain, if one was created.
/// </summary>
/// <param name="disposing">True if called from Dispose(), false if from finalizer.</param>
protected virtual void Dispose(bool disposing) {
if (mDisposed) {
return;
}
if (disposing) {
// Free *managed* objects here. This is mostly an
// optimization, as such things will be disposed of
// eventually by the GC.
}
// Free unmanaged objects (i.e. the AppDomain).
if (mAppDomain != null) {
DestroyDomain(disposing);
}
mDisposed = true;
}
}
}