C# HttpListener 无 IIS 服务部署:拒绝访问与端口排查 简介面向希望绕开 IIS、以轻量方式搭建自托管 HTTP 服务的 C# 开发者与教学学习者这份源码示例围绕 HttpListener 监听、请求分发与响应处理展开可用于本地接口调试、桌面程序内嵌 Web 入口或简单服务原型验证。压缩包仅 18KB共 17 个文件以 8 个 cs 源码为主体辅以 resx 资源、xml 配置、htm 静态页面、csproj 项目文件及 settings、thmx 等辅助内容便于直接打开阅读与二次修改。已有 2364 人学习说明其在自托管 Web 服务入门场景中具有一定参考度。代码组织上包含服务启动入口、请求处理器、客户端请求模拟线程以及可承载静态页面的 wwwroot 目录和窗体界面相关代码读者可据此理解无 IIS 环境下从监听、接收上下文到返回响应的完整链路并借鉴其项目结构与调试思路。1. 从一条 netsh 命令说起HttpListener 一跑就报拒绝访问某开发者接了个内网小工具的活需求一句话C# Web服务 无需IIS机器是干净的 Windows装 IIS 嫌重方案就定成控制台程序里挂一个HttpListener。代码三十行写完开发机上按 F5 一切正常打包拷到目标机双击立刻弹HttpListenerException: 拒绝访问可端口明明空着。这个坑跟代码一个字都没关系是 Windows 的 URL 保留机制在门口拦人。类似的轻量场景其实特别常见设备配套的配置页、某跨平台系统的回调接收端、图像处理 Demo 的预览服务几十个请求的量犯不上为它铺一套完整 Web 服务器。下面把 SDK 版本、项目骨架、权限配置、排错和验证拆开记一遍照着走能复现。2. 环境与版本选型.NET 8 SDK 这套最省心2.1 三个分支先分清Framework 4.8、.NET 6、.NET 8HttpListener这个类型在 .NET Framework 和 .NETCore 及以后的统一分支里都有API 名字一模一样行为却差着不少所以第一件事不是装 SDK而是先问清楚目标机的情况能不能联网、有没有 IT 管控、是不是要交付单个 exe。分支目标机需要预装什么交付形态适合的场景.NET Framework 4.8Win10 1803 之后系统自带基本零依赖几十 KB 的 exe纯内网、老机器、不想装运行时.NET 6需装运行时或打成自包含中等体积已有工程在 6 上迁移成本高.NET 8需装运行时或打成自包含中等体积新起项目长期维护判断顺序我一般是这样的如果目标机是一批锁死了不允许装任何运行时的办公电脑直接选 .NET Framework 4.8HttpListener在那上面跑了十几年稳得很缺点是新语法用不上、跨平台别想。如果是自己可控的服务器或者要长期迭代的工具选 .NET 8它是长期支持版本工具链、发布选项、单文件打包都比 6 那一代成熟。.NET 6 我基本不推荐新项目用了它的支持周期已经走完装上去等于开局就欠一笔技术债。还有个容易忽略的点.NET Framework 分支下HttpListener是直接压在 HTTP.sys 上的而 .NETCore分支用的是托管实现绝大多数场景没差别但TimeoutManager这类属性在托管实现上支持不完整别指望靠它兜底超时。这个差异在后面配超时的时候会咬人。2.2 安装 SDK装哪一条、装到哪、要不要重启不用上 Visual Studio独立 SDK 安装包就够体积比整套 IDE 小一个量级。有 winget 的机器直接搜包名选 SDK 8 那条装比手动下安装包省事# 先搜出准确的包 ID不同机器上包名可能略有差异 winget search dotnet sdk # 用上一步搜到的 ID 安装装完不要急着开新窗口 winget install --id 上一步搜到的 SDK 8 包 ID -ewinget search这一步别省。包 ID 里带版本号写错一个字符就会装成运行时而不是 SDK后面dotnet new直接报「找不到模板」。装完默认落在C:\Program Files\dotnet\安装器会自己往系统 PATH 里塞dotnet的路径。如果用离线安装包注意两个勾选项一个是「安装位置」除非 C 盘真的紧张否则保持默认改路径之后某些工具链的探测会出玄学问题另一个是遥测开关内网环境关掉省得等超时。装完必须重开一个终端。我见过不止一次「装完了但dotnet不是内部或外部命令」纯粹是旧终端还揣着安装前的环境变量副本。这个血泪经验值得记一笔装任何改 PATH 的东西第一反应都应该是关掉所有命令行窗口。2.3 装完先验环境四条命令确认没装歪SDK 装完别直接开写先跑四条命令把「SDK 有没有、版本对不对、运行时齐不齐、路径指向哪」一次看清dotnet --version # 当前目录生效的 SDK 版本认的是 global.json dotnet --list-sdks # 机器上所有 SDK确认 8.x 在里面 dotnet --list-runtimes # 运行时清单自包含发布时用不到框架依赖发布必须齐 where dotnet # Windows 上看实际调用的是哪个 dotnet.exedotnet --version和--list-sdks的结果可能对不上这不是装坏了。前者受当前目录及各级父目录下global.json的影响后者才是机器真实装了哪些。如果你在某个老项目目录里执行--version很可能返回 6.x这时候新建项目就会莫名其妙用了老 SDK。再验一次模板能不能跑通比看版本号更实在mkdir C:\temp\sdktest cd C:\temp\sdktest dotnet new console -n HelloCheck cd HelloCheck dotnet run能看到Hello, World!就说明 SDK、模板、编译、运行这条链路全通了。这一步花两分钟能省掉后面半小时「到底是代码问题还是环境问题」的扯皮。跑完把C:\temp\sdktest删掉别留着污染以后的目录扫描。3. 写出最小可跑的服务一个 Program.cs 撑起全部3.1 建项目与骨架控制台模板就够了不需要 Web 模板不需要Microsoft.AspNetCore.*那一整套依赖一个空控制台项目就能把这个服务架起来。ASP.NET Core 当然也能自宿主但它带进来的中间件、依赖注入、配置系统对一个几十行的小服务来说是纯粹的负担发布体积也从几 MB 涨到几十 MB。dotnet new console -n MiniWeb -o .\MiniWeb cd .\MiniWeb建完先看一眼 csproj改成下面这样Project SdkMicrosoft.NET.Sdk PropertyGroup OutputTypeExe/OutputType TargetFrameworknet8.0/TargetFramework Nullableenable/Nullable ImplicitUsingsenable/ImplicitUsings !-- 服务要处理中文路径和中文响应全球化不能裁掉 -- InvariantGlobalizationfalse/InvariantGlobalization AssemblyNameMiniWeb/AssemblyName RootNamespaceMiniWeb/RootNamespace /PropertyGroup /ProjectTargetFramework写net8.0就锁定在 8 这一代避免机器上装了多套 SDK 时被global.json带到别的分支上。Nullable打开是为了让编译器帮你盯住Request.Url这种可能为 null 的地方——HttpListener的不少成员是可为空的不开这个选项运行时才炸NullReferenceException。InvariantGlobalization设成false是我踩过坑之后固定加上的设成true能省一点体积但中文排序、日期格式、编码探测全都会退化。目录上就三样东西Program.cs、MiniWeb.csproj再加一个放静态资源的wwwroot文件夹。没有Startup.cs没有appsettings.json配置直接走命令行参数小服务就该有小服务的样子。3.2 监听循环从 GetContext 到把响应关掉整个服务的核心就是下面这四十来行Program.cs全量替换成它using System.Net; using System.Text; // 控制台默认编码在部分中文系统上是 GBK先掰成 UTF-8否则日志里的中文全是问号 Console.OutputEncoding Encoding.UTF8; // 前缀从命令行第一个参数取不给就用默认值换端口时不用重编译 var prefix args.Length 0 ? args[0] : http://:8080/; var listener new HttpListener(); listener.Prefixes.Add(prefix); // 客户端提前断开时不要抛异常否则一个断连就能把主循环带崩 listener.IgnoreWriteExceptions true; try { listener.Start(); // 拒绝访问的高发点权限问题都在这一行暴露 } catch (HttpListenerException ex) { Console.WriteLine($启动失败错误码 {ex.ErrorCode}{ex.Message}); Console.WriteLine(检查 URL ACL 与端口占用见第 4 章); return; } Console.WriteLine($MiniWeb 已启动监听 {prefix}); // 主循环只负责接收连接处理丢给线程池避免一个慢请求卡死后面所有人 while (listener.IsListening) { HttpListenerContext ctx; try { ctx await listener.GetContextAsync(); } catch (HttpListenerException) { break; } // Stop() 时会走到这里正常退出 catch (ObjectDisposedException) { break; } _ HandleAsync(ctx); } listener.Close(); static async Task HandleAsync(HttpListenerContext ctx) { var payload Encoding.UTF8.GetBytes({\ok\:true,\ts\: DateTimeOffset.UtcNow.ToUnixTimeSeconds() }); var res ctx.Response; res.StatusCode 200; res.ContentType application/json; charsetutf-8; // 不设 ContentLength64浏览器会一直转圈等结束标记 res.ContentLength64 payload.Length; try { await res.OutputStream.WriteAsync(payload); } finally { res.Close(); // 这一句同时关流和释放上下文漏了就是句柄泄漏 } }逐段说一下为什么这么写。listener.Start()外面包 try是因为最典型的启动失败就两种权限不足错误码 5和端口已被占错误码 183这两种情况打一行日志退出比抛一堆堆栈好排查得多。主循环里_ HandleAsync(ctx)这个下划线不是装饰。GetContextAsync一次只交出一个连接如果你在里面直接await完整个业务处理那这个服务就成了严格串行——第一个请求只要卡住两秒第二个请求就得等两秒才开始收。丢掉 Task 引用意味着不等待结果请求之间互不影响代价是异常不会冒泡到主线程所以HandleAsync内部必须自己兜住所有异常。res.Close()放在finally里是为了防止写响应时抛异常导致流没关。没关的响应流在 HTTP.sys 侧会一直占着表现出来的现象就是「第一次请求正常第二次浏览器一直转圈」这个问题在第 5 章还会细说。3.3 参数配置前缀格式、端口和并发的那几个数字前缀prefix的写法是这个服务最容易配错的地方它有三种形态语义完全不同前缀写法监听范围需要 URL ACL典型用途http://localhost:8080/仅回环本机不需要本机调试http://:8080/所有网卡需要非管理员时对内提供服务http://192.168.1.20:8080/指定网卡 IP需要多网卡机器和*的区别要记牢HttpListener只认写*在部分环境下会直接抛ArgumentException。另外前缀必须以/结尾少写这个斜杠照样报错报错信息还很不友好。如果你就想本机跑跑用localhost是最省事的方案因为回环地址不需要任何权限配置普通用户直接能起。但只要服务要给别人访问就必须换成然后老老实实去做第 4 章的 URL ACL。端口选择上有两条经验。一是避开 1024 以下的特权端口那需要管理员权限服务一旦做成开机自启会很别扭。二是避开系统可能动态保留的端口段Hyper-V、容器宿主这些东西会抢一段端口你选了个 5000 上下的端口重启一次机器服务就起不来了这个坑在第 4 章的端口排除范围里说。并发这块HttpListener本身没有「最大并发数」这个配置项实际吞吐取决于线程池。默认线程池的起始线程数很小短时间涌进几十个请求时会看到明显的排队。可以在启动时调一下// 最小工作线程数抬高减少突发请求时的排队抖动不要调太离谱线程也是资源 ThreadPool.GetMinThreads(out var worker, out var io); ThreadPool.SetMinThreads(Math.Max(worker, 32), Math.Max(io, 32));放在listener.Start()之前执行参数按实际并发量给几十个请求的小工具给到 32 足够写成 512 只会让 GC 更忙。这个数字不是越大越好调大的收益是突发时首响应更快代价是空闲时内存占用上升。4. 权限与端口配置让服务真正对外可用4.1 URL ACLnetsh 那条命令的正确写法回到第 1 章那个拒绝访问。原因在于 HTTP.sys 对 URL 命名空间做了保留reservation普通用户身份只能监听自己名下的保留项http://:8080/这种通配前缀默认属于管理员。开发机上之所以正常多半是因为 IDE 是以管理员身份启动的——这就是那种「在我这儿好好的」的典型来源。解决方式有两个让程序以管理员运行或者给普通用户授权。前者是个坏习惯一个只处理几十个请求的服务不该常驻高权限所以我一般走第二条路# 给当前登录用户授权监听 8080 的所有前缀 netsh http add urlacl urlhttp://:8080/ user%USERDOMAIN%\%USERNAME% # 查看已有保留项确认加进去了 netsh http show urlacl urlhttp://:8080/ # 授权写错了要撤掉用 delete别用 add 硬覆盖 netsh http delete urlacl urlhttp://:8080/user后面必须写完整的域\用户名形式只写用户名在部分机器上识别不到。%USERDOMAIN%\%USERNAME%在命令行里会自动展开比手动敲稳妥。如果这个服务是以某个服务账户身份跑的就换成那个账户内网受控机器图省事也可以授权给Everyone但这等于放开了本机所有用户的监听权限公网或多人共用的机器上不要这么干。授权之后要重新登录或者重启相关进程才生效改完立刻测试大概率还是老的权限状态。这个细节坑过不少人改完就测、测完就怀疑命令写错了。4.2 防火墙、端口占用与那段被保留的端口区间权限过了下一关是网络。本机 curl 通、同事访问不了九成是防火墙# 只对专用网络放行 8080别用 Public公共网络配置下开放端口风险太大 New-NetFirewallRule -DisplayName MiniWeb 8080 -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Private # 规则建错了先查再删别直接重建名称会冲突 Get-NetFirewallRule -DisplayName MiniWeb 8080 | Format-List Name,Enabled,Profile Remove-NetFirewallRule -DisplayName MiniWeb 8080-Profile Private这个限定很关键。开发机上经常同时挂着公司网络和手机热点如果规则放行到 Public等于在咖啡厅的公共网络里也开了个口子。判断当前网络属于哪个配置文件用Get-NetConnectionProfile。端口占用是另一类高频问题而且有两种完全不同的成因。第一种是真的有进程在占# 找占用 8080 的 PID最后一列就是 netstat -ano | findstr :8080 # 拿到 PID 之后确认是什么进程别直接杀 tasklist /FI PID eq 上面的PID第二种比较阴netstat里看不到任何进程但服务就是起不来。这是 Windows 的动态端口排除区间在作怪Hyper-V、容器宿主会预留一大段端口预留段里的端口任何应用都绑不上# 查看 IPv4 的 TCP 排除区间输出里的 Start Port / End Port 就是被系统扣下的 netsh int ipv4 show excludedportrange protocoltcp看到 8080 落在某个区间里换个端口就行或者用netsh int ipv4 add excludedportrange反向调整预留这个要管理员且会影响容器网络非必要不动。我的习惯是服务端口固定在一个高位段比如 18080 以上避开这些爱打架的区域。4.3 静态文件托管与内容类型映射光返回 JSON 的服务用处有限配套的配置页、预览页总得给几个 HTML。这里不需要什么静态文件中间件路径映射加一张 MIME 表就够// 物理根目录跟着 exe 走单文件发布时也能定位到同级的 wwwroot var root Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, wwwroot)); static async Task ServeFileAsync(HttpListenerContext ctx, string root) { var rel ctx.Request.Url!.AbsolutePath.TrimStart(/); if (string.IsNullOrEmpty(rel)) rel index.html; // 关键一步规范化后必须校验仍在 root 之内否则 ../ 就能读走整块磁盘 var full Path.GetFullPath(Path.Combine(root, rel)); if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase)) { ctx.Response.StatusCode 403; ctx.Response.Close(); return; } if (!File.Exists(full)) { ctx.Response.StatusCode 404; ctx.Response.Close(); return; } var bytes await File.ReadAllBytesAsync(full); ctx.Response.ContentType MimeOf(Path.GetExtension(full)); ctx.Response.ContentLength64 bytes.Length; await ctx.Response.OutputStream.WriteAsync(bytes); ctx.Response.Close(); } static string MimeOf(string ext) ext.ToLowerInvariant() switch { .html or .htm text/html; charsetutf-8, .css text/css; charsetutf-8, .js application/javascript; charsetutf-8, .json application/json; charsetutf-8, .png image/png, .svg image/svgxml, .ico image/x-icon, _ application/octet-stream };Path.GetFullPath加StartsWith这两行是必须的HttpListener不会帮你做任何路径安全检查http://host:8080/../../windows/win.ini这种请求它会原样交到你手上。OrdinalIgnoreCase是因为 Windows 文件系统不区分大小写用序数比较才和磁盘行为一致。AppContext.BaseDirectory而不是Environment.CurrentDirectory这是打包之后才暴露的差别双击 exe 时当前目录是 exe 所在目录但从计划任务或者服务方式启动时当前目录可能是C:\Windows\System32用后者会找不到 wwwroot。这个坑在第 5 章还会出现。MIME 表里没列的类型一律回application/octet-stream浏览器会当下载处理不会白屏。别图省事全回text/html那会让 CSS 和 JS 直接被当 HTML 解析页面样式全丢。5. 常见问题与排查五次翻车现场5.1 启动阶段拒绝访问与访问不到现象一开发机 F5 正常拷到目标机双击就报「拒绝访问」错误码 5。原因IDE 默认以管理员身份运行而目标机上是普通用户双击http://:8080/这个通配前缀没有对应的 URL ACLHTTP.sys 直接拒绝。解决按 4.1 节执行netsh http add urlacl给实际运行账户授权或者把前缀临时改成http://localhost:8080/先验证代码本身没问题。判断依据很简单换 localhost 能起来就是权限问题起不来就是别的问题。现象二本机 curl 通同事的浏览器打不开。原因前缀写成了localhost。这个前缀只绑定回环网卡外部流量根本进不来服务日志里也不会有任何记录看起来就像服务没在跑。解决改成http://:8080/重新做一次 URL ACL再补一条-Profile Private的防火墙入站规则。改完先在本机curl http://本机内网IP:8080/试这一步通了再去让别人试能排除掉一半的沟通成本。现象三listener.Start()抛错误码 183端口已存在。原因端口被占或者落进了系统的动态排除区间。解决先netstat -ano | findstr :8080找 PID找不到再netsh int ipv4 show excludedportrange protocoltcp看排除段。前者是杀进程或者换端口后者只能换端口。我一般直接把服务端口定在 18080 往上从源头绕开。5.2 运行阶段卡死、句柄泄漏与打包后找不到文件现象四第一次请求正常返回刷新第二次浏览器一直转圈最后超时。原因响应流没关或者ContentLength64没设。HTTP 响应靠长度或者连接关闭来界定边界两个都没有客户端就只能死等。解决ContentLength64在写之前设好res.Close()放在finally里保证一定执行。还有个变体是只在成功分支里Close异常分支直接 return表现一样但更难查——所以别在每个 return 前面手写 Close用 finally 统一收口。现象五发布成单文件之后本地跑一切正常目标机上静态页面全 404。原因wwwroot没跟着发布出去或者代码用了Environment.CurrentDirectory拼路径。单文件发布默认只打包程序集wwwroot这种内容文件需要在 csproj 里显式声明为Content并设置CopyToOutputDirectory。解决把wwwroot复制到 exe 同级目录代码里的根路径统一用AppContext.BaseDirectory。还有个容易混的单文件模式下Assembly.Location返回空字符串任何依赖它定位目录的代码都会失效。现象六服务跑了一夜第二天重启报端口被占但netstat里的 PID 是空的。原因进程被强杀任务管理器结束进程、机器断电之后HTTP.sys 侧的注册项没清理干净短时间内端口处于残留状态。解决优先用listener.Stop()加listener.Close()做优雅退出配合Console.CancelKeyPress捕获 CtrlC实在遇到残留了等一两分钟或者换端口不要用taskkill /F当日常操作。这一条属于那种「平时用不上出事时能救命」的后悔药。6. 装完怎么验证跑通三条命令加一次并发回环验证这件事我不太信「打开浏览器看一眼」。手动点开的页面永远只覆盖最顺的那条路径回环、跨机、并发这三条线才是真正暴露问题的。所以每次部署完我都会固定跑一套三连。第一层本机回环验的是程序本身活着# -w 只打状态码-m 3 设三秒超时卡住就不要傻等 curl -s -o NUL -w %{http_code}\n -m 3 http://127.0.0.1:18080/health第二层用内网 IP 访问验的是前缀和防火墙# 把 192.0.2.10 换成目标机实际的内网地址 curl -s -o NUL -w %{http_code}\n -m 3 http://192.0.2.10:18080/health第一条通、第二条不通问题一定在绑定范围或防火墙不用再翻代码。这两条命令的价值就在于把「网络问题」和「代码问题」一刀切开。第三层并发回环验的是主循环有没有写成串行。用 PowerShell 7 的并行管道最省事$sw [Diagnostics.Stopwatch]::StartNew() 1..500 | ForEach-Object -Parallel { (Invoke-WebRequest http://127.0.0.1:18080/health -UseBasicParsing -TimeoutSec 5).StatusCode } -ThrottleLimit 32 | Group-Object | Select-Object Name, Count $sw.Elapsed.TotalSeconds-ThrottleLimit 32同时压 32 个并发跑 500 次。输出里应该只有一行200Count 是 500。如果状态码里混进了 500说明HandleAsync里的异常没兜住如果耗时不正常地长比如几十秒说明主循环里那个_ HandleAsync(ctx)被误写成了await HandleAsync(ctx)请求在排队。对着结果查表会更省事观察到的现象大概率原因先看哪里第一条就不通进程没起来 / 端口错控制台有无启动日志netstat -ano第一条通第二条不通前缀是 localhost / 防火墙前缀写法、入站规则 Profile并发测试混入 500处理函数异常未捕获HandleAsync的 try 覆盖范围并发耗时十几秒以上主循环串行 await主循环有没有丢掉 Task结果全对但内存缓慢上涨响应流未关句柄泄漏每条路径是否都走到Close()补一句/health端点本身。别图省事让它和业务走同一个处理分支单独开一条最短路径只返回固定字符串和当前时间戳不碰文件、不查任何外部依赖。这样它才是真的探活而不是一个会跟着业务一起挂掉的假探针。从那以后我每次部署这套无 IIS 的小服务都会强制把这三条命令跑一遍哪怕只是在同事机器上改了个端口。因为出问题的地方从来不是那四十行代码而是它和操作系统之间那层看不见的权限、端口和路径约定——这层约定一旦对了服务能连跑几个月不出声一旦错了表现又千奇百怪最难的那次我花了两个小时才想起来看端口排除区间。希望这套流程能帮到你少走一遍我走过的弯路。本文还有配套的精品资源点击获取