提交 aa780069 authored 作者: Jeff Lenk's avatar Jeff Lenk

FS-3588 --resolve thanks drk

上级 5e0b3fa0
...@@ -604,6 +604,8 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "mod_opus", "src\mod\codecs\ ...@@ -604,6 +604,8 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "mod_opus", "src\mod\codecs\
EndProject EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "make_t43_gray_code_tables", "libs\spandsp\src\msvc\make_t43_gray_code_tables.2012.vcxproj", "{EDDB8AB9-C53E-44C0-A620-0E86C2CBD5D5}" Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "make_t43_gray_code_tables", "libs\spandsp\src\msvc\make_t43_gray_code_tables.2012.vcxproj", "{EDDB8AB9-C53E-44C0-A620-0E86C2CBD5D5}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "winFailToBan", "src\mod\languages\mod_managed\managed\examples\winFailToBan\winFailToBan.csproj", "{5BA0D5BD-330D-4EE2-B959-CAFEA04E50E0}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
All|Win32 = All|Win32 All|Win32 = All|Win32
...@@ -4056,6 +4058,18 @@ Global ...@@ -4056,6 +4058,18 @@ Global
{EDDB8AB9-C53E-44C0-A620-0E86C2CBD5D5}.Release|x64.Build.0 = All|Win32 {EDDB8AB9-C53E-44C0-A620-0E86C2CBD5D5}.Release|x64.Build.0 = All|Win32
{EDDB8AB9-C53E-44C0-A620-0E86C2CBD5D5}.Release|x64 Setup.ActiveCfg = All|Win32 {EDDB8AB9-C53E-44C0-A620-0E86C2CBD5D5}.Release|x64 Setup.ActiveCfg = All|Win32
{EDDB8AB9-C53E-44C0-A620-0E86C2CBD5D5}.Release|x86 Setup.ActiveCfg = All|Win32 {EDDB8AB9-C53E-44C0-A620-0E86C2CBD5D5}.Release|x86 Setup.ActiveCfg = All|Win32
{5BA0D5BD-330D-4EE2-B959-CAFEA04E50E0}.All|Win32.ActiveCfg = Release|Any CPU
{5BA0D5BD-330D-4EE2-B959-CAFEA04E50E0}.All|x64.ActiveCfg = Release|Any CPU
{5BA0D5BD-330D-4EE2-B959-CAFEA04E50E0}.All|x64 Setup.ActiveCfg = Release|Any CPU
{5BA0D5BD-330D-4EE2-B959-CAFEA04E50E0}.All|x86 Setup.ActiveCfg = Release|Any CPU
{5BA0D5BD-330D-4EE2-B959-CAFEA04E50E0}.Debug|Win32.ActiveCfg = Debug|Any CPU
{5BA0D5BD-330D-4EE2-B959-CAFEA04E50E0}.Debug|x64.ActiveCfg = Debug|Any CPU
{5BA0D5BD-330D-4EE2-B959-CAFEA04E50E0}.Debug|x64 Setup.ActiveCfg = Debug|Any CPU
{5BA0D5BD-330D-4EE2-B959-CAFEA04E50E0}.Debug|x86 Setup.ActiveCfg = Debug|Any CPU
{5BA0D5BD-330D-4EE2-B959-CAFEA04E50E0}.Release|Win32.ActiveCfg = Release|Any CPU
{5BA0D5BD-330D-4EE2-B959-CAFEA04E50E0}.Release|x64.ActiveCfg = Release|Any CPU
{5BA0D5BD-330D-4EE2-B959-CAFEA04E50E0}.Release|x64 Setup.ActiveCfg = Release|Any CPU
{5BA0D5BD-330D-4EE2-B959-CAFEA04E50E0}.Release|x86 Setup.ActiveCfg = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
...@@ -4188,6 +4202,7 @@ Global ...@@ -4188,6 +4202,7 @@ Global
{7B077E7F-1BE7-4291-AB86-55E527B25CAC} = {0C808854-54D1-4230-BFF5-77B5FD905000} {7B077E7F-1BE7-4291-AB86-55E527B25CAC} = {0C808854-54D1-4230-BFF5-77B5FD905000}
{7B42BDA1-72C0-4378-A9B6-5C530F8CD61E} = {0C808854-54D1-4230-BFF5-77B5FD905000} {7B42BDA1-72C0-4378-A9B6-5C530F8CD61E} = {0C808854-54D1-4230-BFF5-77B5FD905000}
{834E2B2F-5483-4B80-8FE3-FE48FF76E5C0} = {0C808854-54D1-4230-BFF5-77B5FD905000} {834E2B2F-5483-4B80-8FE3-FE48FF76E5C0} = {0C808854-54D1-4230-BFF5-77B5FD905000}
{5BA0D5BD-330D-4EE2-B959-CAFEA04E50E0} = {0C808854-54D1-4230-BFF5-77B5FD905000}
{692F6330-4D87-4C82-81DF-40DB5892636E} = {4CF6A6AC-07DE-4B9E-ABE1-7F98B64E0BB0} {692F6330-4D87-4C82-81DF-40DB5892636E} = {4CF6A6AC-07DE-4B9E-ABE1-7F98B64E0BB0}
{2286DA73-9FC5-45BC-A508-85994C3317AB} = {4CF6A6AC-07DE-4B9E-ABE1-7F98B64E0BB0} {2286DA73-9FC5-45BC-A508-85994C3317AB} = {4CF6A6AC-07DE-4B9E-ABE1-7F98B64E0BB0}
{66444AEE-554C-11DD-A9F0-8C5D56D89593} = {4CF6A6AC-07DE-4B9E-ABE1-7F98B64E0BB0} {66444AEE-554C-11DD-A9F0-8C5D56D89593} = {4CF6A6AC-07DE-4B9E-ABE1-7F98B64E0BB0}
......
using System;
using System.Collections.Generic;
using System.Linq;
using FreeSWITCH;
using FreeSWITCH.Native;
namespace winFailToBan
{
public static class BanTracker
{
public static int MaxFails = 3;
public static int FailMinutes = 1;
public static int BanMinutes = 1;
public static String BanApi = @"system netsh adv fire add rule name={0} dir=in action=block remoteip={1}";
public static String UnBanApi = @"system netsh adv fire delete rule name={0}";
// Tracker object
public static Dictionary<String, List<DateTime>> MainTracker =
new Dictionary<string, List<DateTime>>();
// Active Ban list Key=IP val=baninfo
public static Dictionary<String, BanInfo> ActiveBans =
new Dictionary<string, BanInfo>();
public static void Startup()
{
LoadSettings();
}
private static void LoadSettings()
{
using (var a = new Api(null))
{
var setting = a.ExecuteString("global_getvar ban_maxfails");
if (!String.IsNullOrEmpty(setting))
MaxFails = int.Parse(setting);
setting = a.ExecuteString("global_getvar ban_failminutes");
if (!String.IsNullOrEmpty(setting))
FailMinutes = int.Parse(setting);
setting = a.ExecuteString("global_getvar ban_banminutes");
if (!String.IsNullOrEmpty(setting))
BanMinutes = int.Parse(setting);
}
}
private static void CleanOld(List<DateTime> l)
{
var expiretime = DateTime.Now.Subtract(new TimeSpan(0, FailMinutes, 0));
var expired = l.Where(x => x < expiretime).ToList();
expired.ForEach(x => l.Remove(x));
}
public static void CleanUp()
{
var templist = new List<String>();
foreach (var kvp in MainTracker)
{
CleanOld(kvp.Value);
if (kvp.Value.Count == 0)
templist.Add(kvp.Key);
}
templist.ForEach(i =>
{
MainTracker.Remove(i);
Log.WriteLine(LogLevel.Critical, "FTB: Removed tracker entry for {0}", i);
}); // remove all the dictinoary entries that are old
templist.Clear();
// now unban the expired bans
templist.AddRange(from kvp in ActiveBans where kvp.Value.Expires < DateTime.Now select kvp.Key);
templist.ForEach(Unban);
}
public static void TrackFailure(String ipAddress)
{
LoadSettings(); // just in case they've changed...
if (ActiveBans.ContainsKey(ipAddress))
return; // don't process again, some delay may happen between the ban, and it taking effect by external system
if (!MainTracker.ContainsKey(ipAddress))
MainTracker.Add(ipAddress, new List<DateTime>());
var l = MainTracker[ipAddress];
CleanOld(l); // clean out the old ones
l.Add(DateTime.Now); // add the failure to the list
Log.WriteLine(LogLevel.Critical, "Fail to ban logging attempt from {0} count is {1}", ipAddress, l.Count);
if (l.Count > MaxFails)
{
// do the ban here
l.Clear();
MainTracker.Remove(ipAddress);
Ban(ipAddress);
}
}
public static void Ban(String ipAddress)
{
Log.WriteLine(LogLevel.Critical, "FTP Banning IP Address {0}", ipAddress);
if (ActiveBans.ContainsKey(ipAddress))
return; // it's already banned so f-it
var bi = new BanInfo();
ActiveBans.Add(ipAddress, bi);
// Execute the ban API callback here
var acmd = String.Format(BanApi, bi.FirewallRuleName, ipAddress);
Log.WriteLine(LogLevel.Critical, "FTB: api command: {0}", acmd);
using (var a = new Api(null))
a.ExecuteString(acmd);
}
public static void Unban(String ipAddress)
{
Log.WriteLine(LogLevel.Critical, "FTB: Unbanning ip address {0}", ipAddress);
if (!ActiveBans.ContainsKey(ipAddress))
return; // nothing to do, it's not banned
var bi = ActiveBans[ipAddress]; // get the ban info
// Execute the unban API
var acmd = String.Format(UnBanApi, bi.FirewallRuleName);
Log.WriteLine(LogLevel.Critical, "FTB: api command: {0}", acmd);
using (var a = new Api(null))
a.ExecuteString(acmd);
ActiveBans.Remove(ipAddress);
}
}
public class BanInfo
{
public String FirewallRuleName { get; set; }
public DateTime Expires { get; set; }
public BanInfo()
{
FirewallRuleName = "ftb-" + Guid.NewGuid().ToString("N");
Expires = DateTime.Now.AddMinutes(BanTracker.BanMinutes);
}
}
}
using System;
using System.Threading;
using FreeSWITCH.Native;
using winFailToBan.Internal;
using FreeSWITCH;
namespace winFailToBan
{
public static class EventLoop
{
public static Boolean Running = true;
private static Thread _eventThread;
public static void StartEvents()
{
if (_eventThread != null)
return;
_eventThread = new Thread(EventMainLoop);
Running = true;
_eventThread.Start();
}
public static void StopEvents()
{
Running = false;
}
public static void EventMainLoop()
{
EventConsumer ec = null;
try
{
ec = new EventConsumer("CUSTOM", "sofia::register_attempt", 100);
ec.bind("SHUTDOWN", String.Empty);
ec.bind("HEARTBEAT", String.Empty);
while (Running)
{
var evt = ec.pop(0, 0);
if (evt == null)
continue;
var en = evt.InternalEvent.GetValueOfHeader("Event-Name");
if (en == "CUSTOM")
en = evt.InternalEvent.GetValueOfHeader("Event-SubClass");
switch (en)
{
case @"sofia::register_attempt":
{
var iev = evt.InternalEvent;
var ar = iev.GetValueOfHeader("auth-result"); // get the value of the result to see if it's the case we want
var ip = iev.GetValueOfHeader("network-ip"); // and the ip address the register came from
if (ar == "FORBIDDEN")
{
BanTracker.TrackFailure(ip);
}
}
break;
case "SHUTDOWN":
Log.WriteLine(LogLevel.Critical,"FTB: Processing Shutdown event");
Running = false;
break;
case "HEARTBEAT":
BanTracker.CleanUp();
break;
default:
break;
}
}
}
catch (Exception exx)
{
Log.WriteLine(LogLevel.Critical, "FailToBan -- Exception in event loop {0}", exx.Message);
}
finally
{
if(ec != null)
ec.Dispose();
_eventThread = null;
}
}
}
}
using FreeSWITCH;
namespace winFailToBan
{
public class Fail2Ban : IApiPlugin , ILoadNotificationPlugin
{
public void Execute(ApiContext context)
{
var cmds = context.Arguments.Split(" ".ToCharArray());
var cmd = cmds[0].ToLower();
switch (cmd)
{
case "shutdown":
Shutdown();
break;
default:
context.Stream.Write("\n\nInvalid Command\n\n");
break;
}
}
public void ExecuteBackground(ApiBackgroundContext context)
{
return;
}
public static void Startup()
{
BanTracker.Startup();
EventLoop.StartEvents();
}
public static void Shutdown()
{
EventLoop.StopEvents();
}
public bool Load()
{
Startup();
return true;
}
}
}
using System;
using FreeSWITCH;
using FreeSWITCH.Native;
namespace winFailToBan.Internal
{
public class ConfigurationEventArgs : EventArgs
{
public SwitchXmlSearchBinding.XmlBindingArgs FsArgs { get; private set; }
public fsConfigDocument Result { get; set; }
public Boolean DontProcess { get; set; }
public ConfigurationEventArgs(SwitchXmlSearchBinding.XmlBindingArgs args)
{
DontProcess = false;
FsArgs = args;
Result = null;
}
}
// Bind XML search function turned into CLR events for ease use
public class ConfigHandler : IDisposable
{
public event EventHandler<ConfigurationEventArgs> DirectoryRequest;
public event EventHandler<ConfigurationEventArgs> DialPlanRequest;
private IDisposable _binder; // object to bind to
public void Dispose()
{
if(_binder != null)
_binder.Dispose();
_binder = null;
}
public String XmlCallback(SwitchXmlSearchBinding.XmlBindingArgs args)
{
String rv = null; // return value
switch (args.Section.ToLower())
{
case "directory":
var dargs = new ConfigurationEventArgs(args);
if (DirectoryRequest != null)
{
var temp = DirectoryRequest;
temp(this, dargs);
if (dargs.DontProcess)
return null;
if (dargs.Result != null)
rv = dargs.Result.ToXMLString();
}
break;
case "dialplan":
var dialargs = new ConfigurationEventArgs(args);
if(DialPlanRequest != null)
{
var temp = DialPlanRequest;
temp(this, dialargs);
if (dialargs.Result != null)
rv = dialargs.Result.ToXMLString();
}
break;
}
return rv ?? new fsNotFoundDocument().ToXMLString();
}
~ConfigHandler()
{
Dispose();
}
public ConfigHandler()
{
_binder = SwitchXmlSearchBinding.Bind(XmlCallback,
switch_xml_section_enum_t.SWITCH_XML_SECTION_DIRECTORY |
switch_xml_section_enum_t.SWITCH_XML_SECTION_DIALPLAN);
}
}
}
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("winFailToBan")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("winFailToBan")]
[assembly: AssemblyCopyright("Copyright © 2012")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
// Setting ComVisible to false makes the types in this assembly not visible
// to COM components. If you need to access a type in this assembly from
// COM, set the ComVisible attribute to true on that type.
[assembly: ComVisible(false)]
// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("a171cab9-d773-4070-9993-26a581c6f243")]
// Version information for an assembly consists of the following four values:
//
// Major Version
// Minor Version
// Build Number
// Revision
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="FreeSWITCHManaged.LibCS" version="1.0.1.10" targetFramework="net40" />
</packages>
\ No newline at end of file
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using FreeSWITCH;
using winFailToBan.Internal;
namespace winFailToBan
{
public class SampleApp : IAppPlugin
{
// example class for a dialplan APP just implment the run method
public void Run(AppContext context)
{
var s = context.Session;
var args = context.Arguments;
// Do something with them here
}
}
// Example class to implment an API command
public class SampleApi : IApiPlugin
{
public void Execute(ApiContext context)
{
throw new NotImplementedException();
}
public void ExecuteBackground(ApiBackgroundContext context)
{
throw new NotImplementedException();
}
}
// This examle class can be used to handle XML config lookups for dialplan and directory
public class SampleConfigHandler : ILoadNotificationPlugin
{
private static ConfigHandler MyConfigHandler;
static void HandleDirectoryLookups(Object sender, ConfigurationEventArgs e)
{
e.Result = null; // not found example just return after this
// return; // uncomment to just return not-fouond
// return a directory object that will work
var evt = e.FsArgs.Parameters; // Get the raw event that generated the userDir lookup
var eventName = evt.GetHeader("Event-Name").value; // Find the event name
// If your module handles voicemail authorization then implment the following
// to update the voicemail password, when they change their voicemail password using TUI
if (eventName == "CUSTOM")
{
var subClass = evt.GetValueOfHeader("Event-Subclass");
if (subClass == "vm::maintenance")
{
var vmaction = evt.GetValueOfHeader("VM-Actoun");
var username = evt.GetValueOfHeader("VM-User");
var newPassword = evt.GetValueOfHeader("VM-User-Password");
if (vmaction == "change-password" && !string.IsNullOrEmpty(username) &&
!String.IsNullOrEmpty(newPassword))
{
// implment your code to update the users vm password in your database
return; // No more processing we don't actually do an auth just a notification
}
}
}
// to make sure we don't have some future events messing us up...
if (eventName != "REQUEST_PARAMS" && eventName != "GENERAL")
return;
// implment the following if you want to handle gateway lookup from directory when a profile loads
if (evt.GetValueOfHeader("purpose") == "gateways")
{
var profileName = evt.GetValueOfHeader("profile");
// implment your gateway lookup
//e.Result = new fsDomainGatewayDirectoryDocument(myGwStructure);
return;
}
var action = evt.GetValueOfHeader("action", "none"); // get the action
// If you want to handle ESL Logins implment the following
if (action == "event_socket_auth")
{
// preform your stuff here
// e.result = ...
return;
}
// Normal lookup processing
if (evt.GetHeader("user") == null || evt.GetHeader("domain") == null)
return; // does't have required fields
var method = evt.GetValueOfHeader("sip_auth_method", "unknown");
var user = evt.GetValueOfHeader("user");
var domain = evt.GetValueOfHeader("domain");
// Some variables to return the params and variables section of the user record
var variables = new Dictionary<String, String>();
var uparams = new Dictionary<String, String>();
// if you're implmenting reverse-auth of devices
if (action == "reverse-auth-lookup")
{
// lookup stuff in your db
uparams.Add("reverse-auth-user", "device uername");
uparams.Add("reverse-auth-pass", "device password");
}
// if you handle voicemail passwords ...
if (true /*check for voicemail box */)
{
uparams.Add("vm-password", "theirvmpassword");
// the following is optional
uparams.Add("MWI-Account", "registrationstring");
}
// add more parameters here
uparams.Add("anyotherparameters", "value");
// add variables here for example
variables.Add("user_context", "theuserscontext");
e.Result = new fsDirectoryDocument(
domain,
user,
"theirpassword",
uparams,
variables);
return;
}
// Example dialplan handler
static void HandleDialPlanRequest(object sender, ConfigurationEventArgs e)
{
var evt = e.FsArgs.Parameters; // get the native event that caused this dialplan lookup
// extract the minimum variables you will need
var context = evt.GetValueOfHeader("Hunt-Context"); // the context
var destination = evt.GetValueOfHeader("Hunt-Destination-Number"); // the dialed number or "DID"
var ani = evt.GetValueOfHeader("Hunt-ANI"); // The ANI/CallerID number
// A place to return the dialplan actions you want
var actions = new List<String>(); // format is "app,data"
// add the actions for your code they shouldn't be static this is just an example
actions.Add("set,continue_on_fail=true");
actions.Add("brige,sofia/mygateway/" + destination);
actions.Add("transfer,fialedDest XML failedcontext");
e.Result = new fsDialPlanDocument(context, actions);
return; // Isn't this easy?
}
public bool Load()
{
// Start any threads doing event consumer loops
MyConfigHandler = new ConfigHandler(); // init a config handler
MyConfigHandler.DirectoryRequest += HandleDirectoryLookups;
MyConfigHandler.DialPlanRequest += HandleDialPlanRequest;
return true;
}
}
}
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProductVersion>8.0.30703</ProductVersion>
<SchemaVersion>2.0</SchemaVersion>
<ProjectGuid>{5BA0D5BD-330D-4EE2-B959-CAFEA04E50E0}</ProjectGuid>
<OutputType>Library</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>winFailToBan</RootNamespace>
<AssemblyName>winFailToBan</AssemblyName>
<TargetFrameworkVersion>v4.0</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Data" />
<Reference Include="System.Xml" />
</ItemGroup>
<ItemGroup>
<Compile Include="BanTracker.cs" />
<Compile Include="EventLoop.cs" />
<Compile Include="Fail2Ban.cs" />
<Compile Include="Internal\ConfigHandler.cs" />
<Compile Include="Internal\ConfigHelper.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<None Include="skel.cs" />
</ItemGroup>
<ItemGroup>
<None Include="packages.config" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\FreeSWITCH.Managed.2012.csproj">
<Project>{834e2b2f-5483-4b80-8fe3-fe48ff76e5c0}</Project>
<Name>FreeSWITCH.Managed.2012</Name>
</ProjectReference>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<!-- To modify your build process, add your task inside one of the targets below and uncomment it.
Other similar extension points exist, see Microsoft.Common.targets.
<Target Name="BeforeBuild">
</Target>
<Target Name="AfterBuild">
</Target>
-->
</Project>
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论