Compare commits

...
25 changed files with 1961 additions and 19 deletions

No files matched your search

+8
View File
@@ -74,6 +74,14 @@ if(APPLE AND NOT IOS)
endif()
execute_process(
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
COMMAND git rev-parse --short HEAD
OUTPUT_VARIABLE GIT_COMMIT_HASH
OUTPUT_STRIP_TRAILING_WHITESPACE
)
add_definitions(-DGIT_COMMIT_HASH="${GIT_COMMIT_HASH}")
add_subdirectory(client)
if(NOT IOS AND NOT ANDROID AND NOT MACOS_NE)
-9
View File
@@ -16,15 +16,6 @@ set(PACKAGES
Core5Compat Concurrent LinguistTools
)
execute_process(
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
COMMAND git rev-parse --short HEAD
OUTPUT_VARIABLE GIT_COMMIT_HASH
OUTPUT_STRIP_TRAILING_WHITESPACE
)
add_definitions(-DGIT_COMMIT_HASH="${GIT_COMMIT_HASH}")
add_definitions(-DPROD_AGW_PUBLIC_KEY="$ENV{PROD_AGW_PUBLIC_KEY}")
add_definitions(-DPROD_S3_ENDPOINT="$ENV{PROD_S3_ENDPOINT}")
add_definitions(-DFALLBACK_S3_ENDPOINT="$ENV{FALLBACK_S3_ENDPOINT}")
+1
View File
@@ -20,6 +20,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<!-- To request network state -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.CAMERA" />
@@ -7,12 +7,16 @@ import android.app.NotificationManager
import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.Intent.EXTRA_MIME_TYPES
import android.content.Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.NetworkCapabilities
import android.net.Uri
import android.net.VpnService
import android.os.Build
@@ -44,6 +48,8 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import java.io.File
import java.io.IOException
import java.net.NetworkInterface
import java.util.Collections
import kotlin.LazyThreadSafetyMode.NONE
import kotlin.coroutines.CoroutineContext
import kotlin.text.RegexOption.IGNORE_CASE
@@ -906,6 +912,114 @@ class AmneziaActivity : QtActivity() {
}
}
// Counterpart to the desktop shell-script diagnostics (networkdiagnostics.cpp): Android's
// app sandbox has no way to shell out to system network tools, so this collects the
// closest equivalent through public framework APIs instead. Output uses the same
// "=== section ===" heading style as the desktop scripts so Logger::runNetworkDiagnostics()
// can save it without any format-specific handling on the C++ side.
@Suppress("unused")
fun runNetworkDiagnostics(): String {
Log.v(TAG, "Run network diagnostics")
val out = StringBuilder()
fun section(name: String, block: () -> Unit) {
out.append("=== ").append(name).append(" ===\n")
try {
block()
} catch (e: Exception) {
out.append("ERROR: ").append(e.toString()).append('\n')
}
out.append('\n')
}
section("system") {
out.append("Android: ").append(Build.VERSION.RELEASE)
.append(" (SDK ").append(Build.VERSION.SDK_INT).append(")\n")
out.append("Device: ").append(Build.MANUFACTURER).append(' ').append(Build.MODEL).append('\n')
}
val connectivityManager = applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
section("link-type") {
if (connectivityManager == null) {
out.append("ConnectivityManager unavailable\n")
return@section
}
val activeNetwork = connectivityManager.activeNetwork
val caps = activeNetwork?.let { connectivityManager.getNetworkCapabilities(it) }
if (activeNetwork == null || caps == null) {
out.append("type=none (no active network)\n")
return@section
}
val type = when {
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "vpn-tunnel"
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
else -> "other"
}
out.append("type=").append(type).append('\n')
out.append("validated=").append(caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)).append('\n')
out.append("internet=").append(caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)).append('\n')
out.append("metered=").append(!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)).append('\n')
}
section("adapters") {
val interfaces = try {
Collections.list(NetworkInterface.getNetworkInterfaces())
} catch (e: Exception) {
out.append("Failed to enumerate interfaces: ").append(e.toString()).append('\n')
emptyList()
}
for (ni in interfaces) {
out.append(ni.name)
.append(": up=").append(ni.isUp)
.append(" loopback=").append(ni.isLoopback)
.append(" mtu=").append(runCatching { ni.mtu }.getOrDefault(-1))
.append('\n')
for (addr in ni.inetAddresses) {
out.append(" addr: ").append(addr.hostAddress).append('\n')
}
}
}
section("routes") {
if (connectivityManager == null) {
out.append("ConnectivityManager unavailable\n")
return@section
}
val activeNetwork = connectivityManager.activeNetwork
val linkProperties: LinkProperties? = activeNetwork?.let { connectivityManager.getLinkProperties(it) }
if (linkProperties == null) {
out.append("no active network\n")
return@section
}
out.append("interfaceName=").append(linkProperties.interfaceName ?: "unknown").append('\n')
for (route in linkProperties.routes) {
out.append(route.toString()).append('\n')
}
}
section("dns") {
if (connectivityManager == null) {
out.append("ConnectivityManager unavailable\n")
return@section
}
val activeNetwork = connectivityManager.activeNetwork
val linkProperties: LinkProperties? = activeNetwork?.let { connectivityManager.getLinkProperties(it) }
val dnsServers = linkProperties?.dnsServers ?: emptyList()
if (dnsServers.isEmpty()) {
out.append("no DNS servers reported\n")
} else {
for (dns in dnsServers) {
out.append(dns.hostAddress).append('\n')
}
}
}
return out.toString()
}
@Suppress("unused")
@SuppressLint("UnsupportedChromeOsCameraSystemFeature")
fun isCameraPresent(): Boolean = applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA)
+5
View File
@@ -15,6 +15,7 @@ find_library(FW_FOUNDATION Foundation)
find_library(FW_STOREKIT StoreKit)
find_library(FW_USERNOTIFICATIONS UserNotifications)
find_library(FW_NETWORKEXTENSION NetworkExtension)
find_library(LIB_RESOLV resolv)
set(LIBS ${LIBS}
${FW_AUTHENTICATIONSERVICES}
@@ -24,6 +25,7 @@ set(LIBS ${LIBS}
${FW_STOREKIT}
${FW_USERNOTIFICATIONS}
${FW_NETWORKEXTENSION}
${LIB_RESOLV}
)
@@ -34,6 +36,7 @@ set(HEADERS ${HEADERS}
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ioscontextmenu.h
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate.h
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate-C-Interface.h
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_network_diagnostics.h
)
set_source_files_properties(${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller.h PROPERTIES OBJECTIVE_CPP_HEADER TRUE)
@@ -47,6 +50,7 @@ set(SOURCES ${SOURCES}
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QRCodeReaderBase.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/AmneziaSceneDelegateHooks.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_network_diagnostics.mm
)
# The context menu helper uses ARC-only constructs (weak references); the
@@ -122,6 +126,7 @@ target_sources(${PROJECT} PRIVATE
${CLIENT_ROOT_DIR}/platforms/ios/Log.swift
${CLIENT_GROUP_IDENTIFIER_SWIFT_FILE}
${CLIENT_ROOT_DIR}/platforms/ios/LogRecord.swift
${CLIENT_ROOT_DIR}/platforms/ios/NetworkDiagnosticsController.swift
${CLIENT_ROOT_DIR}/platforms/ios/ScreenProtection.swift
${CLIENT_ROOT_DIR}/platforms/ios/VPNCController.swift
${CLIENT_ROOT_DIR}/platforms/ios/StoreKit2Helper.swift
@@ -196,6 +196,13 @@ QString AndroidController::getFileName(const QString &uri)
return AndroidUtils::convertJString(env.jniEnv(), fileName.object<jstring>());
}
QString AndroidController::runNetworkDiagnostics()
{
auto result = callActivityMethod<jstring>("runNetworkDiagnostics", "()Ljava/lang/String;");
QJniEnvironment env;
return AndroidUtils::convertJString(env.jniEnv(), result.object<jstring>());
}
bool AndroidController::isCameraPresent()
{
return callActivityMethod<jboolean>("isCameraPresent", "()Z");
@@ -62,6 +62,8 @@ public:
QJsonObject acknowledgePurchase(const QString &purchaseToken);
QJsonObject queryPurchases();
QString runNetworkDiagnostics();
static bool initLogging();
static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message);
@@ -0,0 +1,141 @@
import Foundation
import Network
import UIKit
private func section(_ out: inout String, _ name: String, _ block: () -> String) {
out += "=== \(name) ===\n"
out += block()
out += "\n"
}
private func pathStatusDescription(_ status: NWPath.Status) -> String {
switch status {
case .satisfied: return "satisfied"
case .unsatisfied: return "unsatisfied"
case .requiresConnection: return "requiresConnection"
@unknown default: return "unknown"
}
}
private func linkTypeDescription(_ path: NWPath) -> String {
if path.usesInterfaceType(.wifi) {
return "wifi"
} else if path.usesInterfaceType(.cellular) {
return "cellular"
} else if path.usesInterfaceType(.wiredEthernet) {
return "ethernet"
} else if path.usesInterfaceType(.loopback) {
return "loopback"
} else if path.usesInterfaceType(.other) {
return "vpn-tunnel-or-other"
}
return "none"
}
private func currentPath(timeout: TimeInterval = 2.0) -> NWPath? {
let monitor = NWPathMonitor()
let semaphore = DispatchSemaphore(value: 0)
var result: NWPath?
let queue = DispatchQueue(label: "org.amnezia.network-diagnostics")
monitor.pathUpdateHandler = { path in
result = path
semaphore.signal()
}
monitor.start(queue: queue)
_ = semaphore.wait(timeout: .now() + timeout)
monitor.cancel()
return result
}
private func collectSystemInfo() -> String {
let device = UIDevice.current
var out = ""
out += "iOS: \(device.systemVersion)\n"
out += "Device: \(device.model) (\(device.name))\n"
return out
}
private func collectLinkType(_ path: NWPath?) -> String {
guard let path else {
return "type=none (no path reported)\n"
}
var out = ""
out += "type=\(linkTypeDescription(path))\n"
out += "status=\(pathStatusDescription(path.status))\n"
out += "expensive=\(path.isExpensive)\n"
out += "constrained=\(path.isConstrained)\n"
return out
}
private func collectAdapters() -> String {
var out = ""
var ifaddrPtr: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&ifaddrPtr) == 0, let firstAddr = ifaddrPtr else {
out += "Failed to enumerate interfaces\n"
return out
}
defer { freeifaddrs(ifaddrPtr) }
var addressesByInterface: [String: [String]] = [:]
var flagsByInterface: [String: UInt32] = [:]
for ptr in sequence(first: firstAddr, next: { $0.pointee.ifa_next }) {
let interface = ptr.pointee
let name = String(cString: interface.ifa_name)
flagsByInterface[name] = interface.ifa_flags
let family = interface.ifa_addr?.pointee.sa_family
guard family == UInt8(AF_INET) || family == UInt8(AF_INET6) else { continue }
var hostBuffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let result = getnameinfo(interface.ifa_addr, socklen_t(interface.ifa_addr.pointee.sa_len),
&hostBuffer, socklen_t(hostBuffer.count), nil, 0, NI_NUMERICHOST)
guard result == 0 else { continue }
let address = String(cString: hostBuffer)
addressesByInterface[name, default: []].append(address)
}
for name in flagsByInterface.keys.sorted() {
let flags = flagsByInterface[name] ?? 0
let isUp = (flags & UInt32(IFF_UP)) != 0
let isLoopback = (flags & UInt32(IFF_LOOPBACK)) != 0
out += "\(name): up=\(isUp) loopback=\(isLoopback)\n"
for address in addressesByInterface[name] ?? [] {
out += " addr: \(address)\n"
}
}
return out
}
private func collectRoutes(_ path: NWPath?) -> String {
guard let path else {
return "no active path\n"
}
var out = ""
// NWPath can report the same interface twice (once per IP family); keep first occurrences.
var seenNames = Set<String>()
let interfaceNames = path.availableInterfaces.map { $0.name }.filter { seenNames.insert($0).inserted }
out += "availableInterfaces=\(interfaceNames.joined(separator: ", "))\n"
if let gatewayInterface = path.availableInterfaces.first {
out += "primaryInterface=\(gatewayInterface.name) (\(gatewayInterface.type))\n"
}
out += "supportsIPv4=\(path.supportsIPv4)\n"
out += "supportsIPv6=\(path.supportsIPv6)\n"
out += "supportsDNS=\(path.supportsDNS)\n"
return out
}
public func swiftRunNetworkDiagnostics(_ dnsServers: std.string) -> std.string {
var out = ""
let path = currentPath()
section(&out, "system") { collectSystemInfo() }
section(&out, "link-type") { collectLinkType(path) }
section(&out, "adapters") { collectAdapters() }
section(&out, "routes") { collectRoutes(path) }
section(&out, "dns") { String(describing: dnsServers) }
return std.string(out)
}
@@ -0,0 +1,8 @@
#ifndef IOS_NETWORK_DIAGNOSTICS_H
#define IOS_NETWORK_DIAGNOSTICS_H
#include <QString>
QString iosDnsServersDiagnostics();
#endif // IOS_NETWORK_DIAGNOSTICS_H
@@ -0,0 +1,38 @@
#include "ios_network_diagnostics.h"
#include <arpa/inet.h>
#include <netinet/in.h>
#include <resolv.h>
#include <cstdlib>
QString iosDnsServersDiagnostics()
{
res_state state = static_cast<res_state>(calloc(1, sizeof(struct __res_state)));
if (!state) {
return QStringLiteral("failed to allocate resolver state\n");
}
QString out;
if (res_ninit(state) == 0) {
union res_sockaddr_union addrs[MAXNS];
const int count = res_getservers(state, addrs, MAXNS);
for (int i = 0; i < count; ++i) {
char buf[INET6_ADDRSTRLEN] = {};
if (addrs[i].sin.sin_family == AF_INET
&& inet_ntop(AF_INET, &addrs[i].sin.sin_addr, buf, sizeof(buf))) {
out += QString::fromLatin1(buf) + QLatin1Char('\n');
} else if (addrs[i].sin6.sin6_family == AF_INET6
&& inet_ntop(AF_INET6, &addrs[i].sin6.sin6_addr, buf, sizeof(buf))) {
out += QString::fromLatin1(buf) + QLatin1Char('\n');
}
}
res_ndestroy(state);
}
free(state);
if (out.isEmpty()) {
return QStringLiteral("no DNS servers reported\n");
}
return out;
}
@@ -131,6 +131,18 @@ void SettingsUiController::clearLogs()
qInfo().noquote() << QString("SSL backend: %1").arg(QSslSocket::sslLibraryVersionString());
}
bool SettingsUiController::runNetworkDiagnostics()
{
return Logger::runNetworkDiagnostics();
}
void SettingsUiController::exportNetworkDiagnosticsFile(const QString &fileName)
{
if (!SystemController::saveFile(fileName, Logger::getNetworkDiagnosticsFile())) {
qInfo() << "SettingsUiController::exportNetworkDiagnosticsFile: save or share was cancelled or failed";
}
}
void SettingsUiController::backupAppConfig(const QString &fileName)
{
QByteArray data = m_settingsController->backupAppConfig();
@@ -51,6 +51,9 @@ public slots:
void exportServiceLogsFile(const QString &fileName);
void clearLogs();
bool runNetworkDiagnostics();
void exportNetworkDiagnosticsFile(const QString &fileName);
void backupAppConfig(const QString &fileName);
void restoreAppConfig(const QString &fileName);
void restoreAppConfigFromData(const QByteArray &data);
+88 -9
View File
@@ -100,6 +100,45 @@ PageType {
showQuestionDrawer(headerText, "", yesButtonText, noButtonText, yesButtonFunction, noButtonFunction)
}
}
DividerType {
visible: networkDiagnosticsAvailable
}
LabelWithButtonType {
Layout.fillWidth: true
Layout.topMargin: -8
visible: networkDiagnosticsAvailable
text: qsTr("Run network diagnostics")
leftImageSource: "qrc:/images/controls/scan-line.svg"
isSmallLeftImage: true
clickedFunction: function() {
var headerText = qsTr("Run network diagnostics?")
var descriptionText = qsTr("This collects local network configuration (adapters, routes, DNS and similar) and saves it as a new Amnezia-network log file, named with the current date and time. It takes a few seconds and does not open any window.")
var yesButtonText = qsTr("Continue")
var noButtonText = qsTr("Cancel")
var yesButtonFunction = function() {
PageController.showBusyIndicator(true)
var success = SettingsController.runNetworkDiagnostics()
PageController.showBusyIndicator(false)
if (success) {
PageController.showNotificationMessage(qsTr("Network diagnostics saved"))
} else {
PageController.showNotificationMessage(qsTr("Network diagnostics failed"))
}
}
var noButtonFunction = function() {
}
showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, noButtonFunction)
}
}
}
model: logTypes
@@ -165,15 +204,24 @@ PageType {
}
}
// Show service logs only if this is NOT a macOS build with
// Network-Extension (IsMacOsNeBuild is injected from C++ at run-time)
// or if this is NOT a mobile build
property list<QtObject> logTypes: (IsMacOsNeBuild || GC.isMobile()) ? [
clientLogs
] : [
clientLogs,
serviceLogs
]
readonly property bool networkDiagnosticsAvailable: Qt.platform.os === "windows" || Qt.platform.os === "linux"
|| Qt.platform.os === "osx" || Qt.platform.os === "android" || Qt.platform.os === "ios"
// Mobile platforms and macOS Network-Extension builds have no separate background
// service process, so there are no service logs to show. Network diagnostics on
// mobile is collected in-process (not via a service), so it's independent of that
// and only gated by networkDiagnosticsAvailable above.
property list<QtObject> logTypes: {
if (IsMacOsNeBuild) {
return [clientLogs]
} else if (GC.isMobile()) {
return networkDiagnosticsAvailable ? [clientLogs, networkDiagnosticsLog] : [clientLogs]
} else if (networkDiagnosticsAvailable) {
return [clientLogs, serviceLogs, networkDiagnosticsLog]
} else {
return [clientLogs, serviceLogs]
}
}
QtObject {
id: clientLogs
@@ -228,4 +276,35 @@ PageType {
}
}
}
QtObject {
id: networkDiagnosticsLog
readonly property string title: qsTr("Network diagnostics")
readonly property string description: qsTr("Local network configuration snapshot collected via the background service")
readonly property bool isVisible: true
readonly property var openLogsHandler: function() {
SettingsController.openLogsFolder()
}
readonly property var exportLogsHandler: function() {
var fileName = ""
if (GC.isMobile()) {
var timestamp = Qt.formatDateTime(new Date(), "yyyy-MM-dd_HH-mm-ss")
fileName = "Amnezia-network-" + timestamp + ".log"
} else {
var timestamp = Qt.formatDateTime(new Date(), "yyyy-MM-dd_HH-mm-ss")
fileName = SystemController.getFileName(qsTr("Save"),
qsTr("Logs files (*.log)"),
StandardPaths.standardLocations(StandardPaths.DocumentsLocation) + "/Amnezia-network-" + timestamp,
true,
".log")
}
if (fileName !== "") {
PageController.showBusyIndicator(true)
SettingsController.exportNetworkDiagnosticsFile(fileName)
PageController.showBusyIndicator(false)
PageController.showNotificationMessage(qsTr("Logs file saved"))
}
}
}
}
+87
View File
@@ -7,6 +7,7 @@
#include <QJsonDocument>
#include <QMetaEnum>
#include <QStandardPaths>
#include <QSysInfo>
#include <QUrl>
#include "core/utils/utilities.h"
@@ -16,8 +17,13 @@
#include <core/utils/ipcClient.h>
#endif
#ifdef Q_OS_ANDROID
#include "platforms/android/android_controller.h"
#endif
#ifdef Q_OS_IOS
#include "core/utils/swiftBridge.h"
#include "platforms/ios/ios_network_diagnostics.h"
#endif
QFile Logger::m_file;
@@ -104,6 +110,58 @@ bool Logger::setServiceLogsEnabled(bool enabled)
return true;
}
bool Logger::saveNetworkDiagnosticsResult(const QString &result)
{
if (result.startsWith(QLatin1String("ERROR:"))) {
qWarning() << "Logger::runNetworkDiagnostics():" << result;
return false;
}
QDir().mkpath(userLogsDir());
const QString path = newNetworkDiagnosticsFilePath();
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
qWarning() << "Logger::runNetworkDiagnostics(): failed to open" << path;
return false;
}
QTextStream ts(&file);
ts << QString("===== Amnezia network diagnostics - %1 =====\n%2 (%3)\n%4 %5 %6\n\n")
.arg(QDateTime::currentDateTimeUtc().toString("yyyy-MM-dd hh:mm:ss.zzz'Z'"),
QSysInfo::prettyProductName(), QSysInfo::currentCpuArchitecture(),
APPLICATION_NAME, APP_VERSION, GIT_COMMIT_HASH);
ts << result << "\n\n";
qDebug() << "Logger::runNetworkDiagnostics(): saved to" << path;
return true;
}
bool Logger::runNetworkDiagnostics()
{
#ifdef AMNEZIA_DESKTOP
return IpcClient::withInterface([](QSharedPointer<IpcInterfaceReplica> iface) {
QRemoteObjectPendingReply<QString> reply = iface->runNetworkDiagnostics();
// Must exceed the service-side script timeout (30s, networkdiagnostics.cpp)
// plus margin for the IPC round-trip.
if (!reply.waitForFinished(35000)) {
qWarning() << "Logger::runNetworkDiagnostics(): IPC call timed out";
return false;
}
return saveNetworkDiagnosticsResult(reply.returnValue());
}, []() {
qWarning() << "Logger::runNetworkDiagnostics(): Service is not running";
return false;
});
#elif defined(Q_OS_ANDROID)
return saveNetworkDiagnosticsResult(AndroidController::instance()->runNetworkDiagnostics());
#elif defined(Q_OS_IOS)
return saveNetworkDiagnosticsResult(QString::fromStdString(
AmneziaVPN::swiftRunNetworkDiagnostics(iosDnsServersDiagnostics().toStdString())));
#else
return false;
#endif
}
QString Logger::userLogsDir()
{
return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/log";
@@ -135,6 +193,24 @@ QString Logger::serviceLogsFilePath()
return systemLogDir() + QDir::separator() + m_serviceLogFileName;
}
QString Logger::newNetworkDiagnosticsFilePath()
{
const QString timestamp = QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm-ss");
return userLogsDir() + QDir::separator() + QStringLiteral("Amnezia-network-%1.log").arg(timestamp);
}
QString Logger::latestNetworkDiagnosticsFilePath()
{
QDir dir(userLogsDir());
// "yyyy-MM-dd_HH-mm-ss" sorts lexicographically in chronological order,
// so the last entry in a name-sorted listing is the most recent run.
const QStringList files = dir.entryList({ QStringLiteral("Amnezia-network-*.log") }, QDir::Files, QDir::Name);
if (files.isEmpty()) {
return QString();
}
return dir.filePath(files.last());
}
QString Logger::getLogFile()
{
if (m_file.isOpen()) {
@@ -169,6 +245,17 @@ QString Logger::getServiceLogFile()
#endif
}
QString Logger::getNetworkDiagnosticsFile()
{
const QString path = latestNetworkDiagnosticsFilePath();
if (path.isEmpty()) {
return QString();
}
QFile file(path);
file.open(QIODevice::ReadOnly);
return QString::fromUtf8(file.readAll());
}
bool Logger::openLogsFolder(bool isServiceLogger)
{
QString path = isServiceLogger ? systemLogDir() : userLogsDir();
+8
View File
@@ -20,6 +20,7 @@ public:
static void deInit();
static bool setServiceLogsEnabled(bool enabled);
static bool runNetworkDiagnostics();
static bool openLogsFolder(bool isServiceLogger);
@@ -29,10 +30,13 @@ public:
static QString userLogsFilePath();
static QString serviceLogsFilePath();
static QString newNetworkDiagnosticsFilePath();
static QString latestNetworkDiagnosticsFilePath();
static QString systemLogDir();
static QString getLogFile();
static QString getServiceLogFile();
static QString getNetworkDiagnosticsFile();
// compat with Mozilla logger
Logger(const QString &className)
@@ -101,6 +105,10 @@ private:
Logger() = default;
static QString userLogsDir();
// Validates a raw diagnostics result (rejects "ERROR: ..." strings) and, if valid,
// writes it to a new timestamped network-diagnostics log file with the standard header.
// Shared by every platform's runNetworkDiagnostics() backend.
static bool saveNetworkDiagnosticsResult(const QString &result);
static QFile m_file;
static QTextStream m_textStream;
+4
View File
@@ -44,6 +44,10 @@ class IpcInterface
SLOT( bool startNetworkCheck(const QString& serverIpv4Gateway, const QString& deviceIpv4Address) );
SLOT( bool stopNetworkCheck() );
// Runs the bundled platform diagnostics script and returns the concatenated
// section output, or "ERROR: <reason>" on failure.
SLOT( QString runNetworkDiagnostics() );
SIGNAL( connectionLose() );
SIGNAL( wakeup() );
SIGNAL( networkChanged() );
+10
View File
@@ -16,6 +16,7 @@
#include "logger.h"
#include "router.h"
#include "killswitch.h"
#include "networkdiagnostics.h"
#include "xray.h"
#ifdef Q_OS_WIN
@@ -241,6 +242,15 @@ bool IpcServer::stopNetworkCheck()
return true;
}
QString IpcServer::runNetworkDiagnostics()
{
#ifdef MZ_DEBUG
qDebug() << "IpcServer::runNetworkDiagnostics";
#endif
return NetworkDiagnostics::run();
}
bool IpcServer::resetKillSwitchAllowedRange(QStringList ranges)
{
#ifdef MZ_DEBUG
+1
View File
@@ -46,6 +46,7 @@ public:
virtual bool xrayStop() override;
virtual bool startNetworkCheck(const QString& serverIpv4Gateway, const QString& deviceIpv4Address) override;
virtual bool stopNetworkCheck() override;
virtual QString runNetworkDiagnostics() override;
private:
int m_localpid = 0;
+7 -1
View File
@@ -32,6 +32,7 @@ set(HEADERS
${CMAKE_CURRENT_LIST_DIR}/killswitch.h
${CMAKE_CURRENT_LIST_DIR}/systemservice.h
${CMAKE_CURRENT_LIST_DIR}/xray.h
${CMAKE_CURRENT_LIST_DIR}/networkdiagnostics.h
${CMAKE_CURRENT_BINARY_DIR}/version.h
)
@@ -49,6 +50,7 @@ set(SOURCES
${CMAKE_CURRENT_LIST_DIR}/killswitch.cpp
${CMAKE_CURRENT_LIST_DIR}/systemservice.cpp
${CMAKE_CURRENT_LIST_DIR}/xray.cpp
${CMAKE_CURRENT_LIST_DIR}/networkdiagnostics.cpp
)
# Mozilla headres
@@ -275,6 +277,10 @@ if(LINUX)
endif()
qt6_add_resources(QRC ${QRC}
${CMAKE_CURRENT_LIST_DIR}/network_diagnostics/networkDiagnostics.qrc
)
include(${CMAKE_CURRENT_LIST_DIR}/../src/qtservice.cmake)
include_directories(
@@ -287,7 +293,7 @@ include_directories(
)
add_executable(${PROJECT} ${SOURCES} ${HEADERS} ${RESOURCES})
add_executable(${PROJECT} ${SOURCES} ${HEADERS} ${RESOURCES} ${QRC})
target_link_libraries(${PROJECT} PRIVATE Qt6::Core Qt6::Widgets Qt6::Network Qt6::RemoteObjects Qt6::Core5Compat Qt6::DBus Qt6::Concurrent ${LIBS})
target_compile_definitions(${PROJECT} PRIVATE "MZ_$<UPPER_CASE:${MZ_PLATFORM_NAME}>")
@@ -0,0 +1,433 @@
#!/usr/bin/env bash
# amnezia-baseline-snapshot.sh
# Linux network state snapshot for establishing a baseline.
# Run with sudo — otherwise some data (socket owners, iptables,
# some modules) will be incomplete.
#
# Usage (three runs):
# sudo ./amnezia-baseline-snapshot.sh --label clean
# sudo ./amnezia-baseline-snapshot.sh --label awg --endpoint <server IP>
# sudo ./amnezia-baseline-snapshot.sh --label xray --endpoint <server IP>
#
# Then compare the folders, e.g.:
# diff snapshot-clean/routes.txt snapshot-awg/routes.txt
set -uo pipefail
LABEL="snapshot"
ENDPOINT=""
while [[ $# -gt 0 ]]; do
case "$1" in
--label) LABEL="$2"; shift 2 ;;
--label=*) LABEL="${1#*=}"; shift ;;
--endpoint) ENDPOINT="$2"; shift 2 ;;
--endpoint=*) ENDPOINT="${1#*=}"; shift ;;
-h|--help)
grep '^#' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
OUT="$(pwd)/snapshot-${LABEL}"
mkdir -p "$OUT"
IS_ROOT=$([ "$(id -u)" -eq 0 ] && echo true || echo false)
have() { command -v "$1" >/dev/null 2>&1; }
# Runs block function $2, writes a timestamped header plus its output
# (stdout+stderr) to $OUT/$1.txt. Errors inside a block don't abort the script.
save() {
local name="$1" fn="$2" path
path="$OUT/$name.txt"
{
echo "=== $name === $(date -Is) (root=$IS_ROOT)"
"$fn" 2>&1
} > "$path"
echo " [ok] $name"
}
echo "Snapshot '$LABEL' -> $OUT"
[ "$IS_ROOT" = true ] || echo " [warn] not running as root — some data will be incomplete"
# --- 1. System ---------------------------------------------------------
system_block() {
echo "--- OS release ---"
cat /etc/os-release 2>/dev/null
echo
echo "--- Kernel / hostname ---"
uname -a
have hostnamectl && hostnamectl 2>&1
echo
echo "IsRoot: $IS_ROOT"
echo "Init system: $(ps -p 1 -o comm= 2>/dev/null)"
}
save "system" system_block
# --- 2. Adapters, including hidden and disabled -----------------------------
# Key for detecting an "overwritten driver" and leftover interfaces.
adapters_block() {
echo "--- ip -d link (all interfaces, including down) ---"
ip -d link show
echo
echo "--- ip addr ---"
ip addr show
echo
echo "--- driver info (ethtool -i) for each interface ---"
for p in /sys/class/net/*; do
ifc=$(basename "$p")
[ "$ifc" = "lo" ] && continue
echo "-- $ifc --"
if have ethtool; then
ethtool -i "$ifc" 2>&1
else
echo "ethtool not installed"
fi
done
echo
echo "--- nmcli device (if NetworkManager is present) ---"
have nmcli && nmcli -f all device show 2>&1
echo
echo "--- leftover/down interfaces with a UP admin state but no carrier ---"
echo "(sign of a stuck virtual interface left behind by a previous VPN session)"
for p in /sys/class/net/*; do
ifc=$(basename "$p")
[ "$ifc" = "lo" ] && continue
flags=$(cat "$p/flags" 2>/dev/null)
operstate=$(cat "$p/operstate" 2>/dev/null)
carrier=$(cat "$p/carrier" 2>/dev/null)
# IFF_UP is bit 0x1 in the flags word
if [ -n "$flags" ] && (( (flags) & 0x1 )) && [ "$carrier" = "0" ]; then
echo "$ifc: operstate=$operstate carrier=$carrier"
fi
done
}
save "adapters" adapters_block
# --- 3. Connection type (cellular / wifi / ethernet) ------------------------
link_type_block() {
echo "--- type detection via /sys and iw ---"
for p in /sys/class/net/*; do
ifc=$(basename "$p")
[ "$ifc" = "lo" ] && continue
t="other"
if [ -d "$p/wireless" ] || { have iw && iw dev "$ifc" info >/dev/null 2>&1; }; then
t="wifi"
elif [[ "$ifc" == wwan* || "$ifc" == ww* || "$ifc" == usb* ]]; then
t="cellular"
elif [[ "$ifc" == en* || "$ifc" == eth* ]]; then
t="ethernet"
elif [[ "$ifc" == wg* || "$ifc" == tun* || "$ifc" == tap* || "$ifc" == awg* ]]; then
t="vpn-tunnel"
fi
operstate=$(cat "$p/operstate" 2>/dev/null)
carrier=$(cat "$p/carrier" 2>/dev/null)
echo "$ifc: type=$t operstate=$operstate carrier=$carrier"
done
echo
echo "--- NetworkManager connection profiles ---"
have nmcli && nmcli connection show 2>&1
echo
echo "--- interface carrying the default route (determines the link type) ---"
dev=$(ip route show default 2>/dev/null | awk '/^default/{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1)}' | head -n1)
if [ -n "$dev" ]; then
metric=$(ip route show default dev "$dev" 2>/dev/null | grep -o 'metric [0-9]*' | head -n1)
echo "default route dev=$dev $metric"
else
echo "no default route"
fi
}
save "link-type" link_type_block
# --- 4. Drivers: kernel modules and network filters --------------------------
# Catches the wireguard module, tun/tap, and netfilter hooks used by
# split-tunnel solutions.
drivers_block() {
echo "--- lsmod (all loaded modules) ---"
lsmod
echo
echo "--- VPN/netfilter related modules ---"
lsmod | grep -E 'wireguard|^tun|^tap|nf_tables|ip_tables|ip6_tables|nfnetlink|xt_|nft_'
echo
echo "--- modinfo wireguard (if kernel module, not a userspace implementation) ---"
have modinfo && modinfo wireguard 2>&1
}
save "drivers" drivers_block
# --- 5. Routes + conflict analysis on metric --------------------------------
routes_block() {
echo "--- ip route (main table) ---"
ip route show
echo
echo "--- ip route show table all ---"
ip route show table all
echo
echo "--- ip -6 route ---"
ip -6 route show table all
echo
echo "--- ip rule / ip -6 rule (policy routing) ---"
ip rule show
ip -6 rule show
echo
echo "--- default routes only ---"
ip route show default
ip -6 route show default
echo
echo "--- interfaces: addresses and indexes ---"
ip -o addr show
echo
echo "--- default route conflicts ---"
echo "Amnezia's norm: the tunnel wins on the LOWER metric (implicit metric 0 also wins)."
echo "Conflict: multiple default routes with equal metric."
for fam in "" "-6"; do
[ -z "$fam" ] && label="IPv4" || label="IPv6"
echo "-- $label --"
# ip prints "metric N" only when the metric was set explicitly; a route without
# it defaults to metric 0, so a missing token is normalized to "0" here.
metrics=$(ip $fam route show default 2>/dev/null | awk '
{ m = 0; for (i = 1; i <= NF; i++) if ($i == "metric") m = $(i+1); print m }')
count=$(ip $fam route show default 2>/dev/null | wc -l)
if [ "$count" -gt 1 ]; then
dup=$(echo "$metrics" | sort | uniq -d)
if [ -n "$dup" ]; then
echo "!!! CONFLICT: multiple $label default routes with equal metric: $dup"
else
echo "OK: $count $label default routes with distinct metrics"
fi
fi
done
echo
echo "--- private subnet reachability vs tunnel ---"
echo "If a private subnet route prefers the tunnel, LAN (router, NAS, printer) becomes unreachable."
ip route show 2>/dev/null | grep -E '^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)'
}
save "routes" routes_block
# --- 6. Actual routing stack resolution --------------------------------------
# What you can't see in `ip route show`: where the packet will ACTUALLY go.
# Also checks the route to the endpoint's own next-hop, since that's where a
# routing loop through the tunnel would actually show up.
route_resolution_block() {
targets=("8.8.8.8" "1.1.1.1")
if [ -n "$ENDPOINT" ]; then targets=("$ENDPOINT" "${targets[@]}"); fi
if [ -n "$ENDPOINT" ]; then
nexthop=$(ip route get "$ENDPOINT" 2>/dev/null | grep -o 'via [0-9a-fA-F:.]*' | awk '{print $2}' | head -n1)
if [ -n "$nexthop" ]; then targets+=("$nexthop"); fi
fi
for t in "${targets[@]}"; do
echo "--- ip route get $t ---"
ip route get "$t" 2>&1
echo
done
if [ -n "$ENDPOINT" ]; then
echo "--- endpoint reachability ---"
outdev=$(ip route get "$ENDPOINT" 2>/dev/null | grep -o 'dev [^ ]*' | awk '{print $2}' | head -n1)
if [ -n "$outdev" ]; then
case "$outdev" in
wg*|awg*|tun*|tap*|utun*)
echo "!!! LOOP: traffic to server $ENDPOINT goes through the VIRTUAL/TUNNEL interface '$outdev'"
;;
*)
echo "OK: traffic to server goes through the physical interface '$outdev'"
;;
esac
else
echo "Could not determine the interface."
fi
echo
echo "--- host route /32 to endpoint (anti-loop guard) ---"
ip route show "$ENDPOINT"/32 2>&1
fi
}
save "route-resolution" route_resolution_block
# --- 7. DNS + leak risk assessment -------------------------------------------
dns_block() {
echo "--- /etc/resolv.conf ---"
cat /etc/resolv.conf 2>&1
echo
echo "--- resolvectl status (systemd-resolved) ---"
have resolvectl && resolvectl status 2>&1
echo
echo "--- resolvectl dns / domain ---"
have resolvectl && { resolvectl dns 2>&1; resolvectl domain 2>&1; }
echo
echo "--- NetworkManager DNS (VPN clients write here via nm) ---"
have nmcli && nmcli device show 2>&1 | grep -i 'DNS\|GENERAL.DEVICE'
echo
echo "--- /etc/nsswitch.conf (hosts line) ---"
grep '^hosts' /etc/nsswitch.conf 2>&1
echo
echo "--- DNS leak risk ---"
tundev=$(ip route show default 2>/dev/null | awk '/^default/{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1)}' | head -n1)
echo "Default route goes through dev=$tundev"
if have resolvectl; then
withdns=$(resolvectl status 2>/dev/null | awk '/^Link/{ifc=$3} /DNS Servers/{print ifc}')
echo "Interfaces with DNS configured: $(echo "$withdns" | grep -c .)"
other=$(echo "$withdns" | grep -v "^${tundev}$" | grep -v '^$')
if [ -n "$other" ]; then
echo "!!! LEAK RISK: DNS is configured on more than just the active interface:"
echo "$other"
else
echo "No obvious risk."
fi
else
echo "resolvectl not available — cannot assess per-interface DNS assignment."
fi
}
save "dns" dns_block
# --- 8. IPv6 and localhost ---------------------------------------------
ipv6_localhost_block() {
echo "--- IP addresses (all families) ---"
ip -o addr show
echo
echo "--- IPv6 disabled via sysctl? ---"
sysctl net.ipv6.conf.all.disable_ipv6 net.ipv6.conf.default.disable_ipv6 2>&1
echo
echo "--- localhost resolution ---"
getent hosts localhost
getent ahosts localhost
echo
echo "--- hosts file ---"
cat /etc/hosts
echo
echo "--- IPv6 leak risk ---"
echo "Sign: a default ::/0 route exists through the tunnel, but the tunnel only has"
echo "ULA (fc00::/7) or link-local addresses - no real IPv6 connectivity."
tun6dev=$(ip -6 route show default 2>/dev/null | awk '/^default/{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1)}' | head -n1)
if [ -n "$tun6dev" ]; then
echo "::/0 goes through dev=$tun6dev"
addrs=$(ip -6 -o addr show dev "$tun6dev" 2>/dev/null | awk '{print $4}')
echo "$addrs"
global=$(echo "$addrs" | grep -vE '^(fe80|fc|fd)')
if [ -z "$global" ]; then
echo "!!! Only ULA/link-local addresses - real IPv6 traffic will leak outside the tunnel."
fi
else
echo "::/0 is absent - no IPv6 routing."
fi
}
save "ipv6-localhost" ipv6_localhost_block
# --- 9. Proxy: environment and system settings ------------------------------
# Key block for Xray: it often works through the system proxy.
proxy_block() {
echo "--- environment variables ---"
env | grep -i proxy
echo
echo "--- /etc/environment ---"
grep -i proxy /etc/environment 2>/dev/null
echo
echo "--- GNOME gsettings proxy (if present) ---"
have gsettings && gsettings get org.gnome.system.proxy mode 2>&1
echo
echo "--- apt proxy config (if present) ---"
cat /etc/apt/apt.conf.d/*proxy* 2>/dev/null
}
save "proxy" proxy_block
# --- 10. VPN/antiDPI services and processes ----------------------------
# Matched by exact process/unit name rather than a broad substring, since a
# substring match also pulls in unrelated software installed on the machine.
services_processes_block() {
local -a known_units=(
amnezia-server amneziawg wg-quick@awg0 wg-quick@wg0
openvpn openvpn-client mullvad-daemon tailscaled
nfqws winws zapret xray-service v2ray sing-box
clash outline-server hiddify NordVPN protonvpn
)
local -a known_procs=(
AmneziaVPN amnezia-service wireguard wg-quick openvpn
mullvad-daemon tailscaled nfqws winws zapret goodbyedpi
byedpi ciadpi xray v2ray sing-box tun2socks clash-verge
)
echo "--- systemd units matching known exact names ---"
if have systemctl; then
systemctl list-units --all --type=service --no-legend 2>/dev/null |
awk -v units="$(IFS='|'; echo "${known_units[*]}")" '
BEGIN { n = split(units, arr, "|") }
{ for (i = 1; i <= n; i++) if (index($1, arr[i]".service") == 1) { print; break } }'
fi
echo
echo "--- processes matching known exact names ---"
for p in "${known_procs[@]}"; do
pgrep -a -x "$p" 2>/dev/null
done
echo
echo "--- listening TCP sockets belonging to those processes ---"
if have ss; then
ss -tlnp 2>&1 | grep -E "$(IFS='|'; echo "${known_procs[*]}")" || echo "(none matched)"
fi
echo
echo "--- UDP endpoints belonging to those processes ---"
if have ss; then
ss -ulnp 2>&1 | grep -E "$(IFS='|'; echo "${known_procs[*]}")" || echo "(none matched)"
fi
echo
echo "--- full listening socket list (unfiltered, for reference) ---"
if have ss; then
ss -tulnp
else
netstat -tulnp
fi
}
save "services-processes" services_processes_block
# --- 11. Firewall / netfilter ------------------------------------------------
firewall_block() {
echo "--- iptables -L -n -v ---"
have iptables && iptables -L -n -v 2>&1
echo
echo "--- ip6tables -L -n -v ---"
have ip6tables && ip6tables -L -n -v 2>&1
echo
echo "--- nft list ruleset (first ~400 lines) ---"
have nft && nft list ruleset 2>&1 | head -n 400
echo "(output truncated; see netfilter-full.txt for the full dump)"
echo
echo "--- ufw status ---"
have ufw && ufw status verbose 2>&1
echo
echo "--- firewalld ---"
if have firewall-cmd; then
firewall-cmd --state 2>&1
firewall-cmd --list-all 2>&1
fi
}
save "firewall" firewall_block
# --- 12. Full netfilter ruleset dump (heavy output, separate file) ----------
netfilter_full_block() {
echo "--- iptables-save ---"
have iptables-save && iptables-save 2>&1
echo
echo "--- ip6tables-save ---"
have ip6tables-save && ip6tables-save 2>&1
echo
echo "--- nft list ruleset (full) ---"
have nft && nft list ruleset 2>&1
}
save "netfilter-full" netfilter_full_block
echo
echo "Done. Folder: $OUT"
echo "Diff of two runs, e.g.:"
echo " diff snapshot-clean/routes.txt snapshot-awg/routes.txt"
@@ -0,0 +1,434 @@
#!/usr/bin/env bash
# amnezia-baseline-snapshot.sh
# macOS network state snapshot for establishing a baseline.
# Run with sudo — otherwise some data (socket owners, pf rules,
# some kext/system_profiler details) will be incomplete.
#
# Usage (three runs):
# sudo ./amnezia-baseline-snapshot.sh --label clean
# sudo ./amnezia-baseline-snapshot.sh --label awg --endpoint <server IP>
# sudo ./amnezia-baseline-snapshot.sh --label xray --endpoint <server IP>
#
# Then compare the folders, e.g.:
# diff snapshot-clean/routes.txt snapshot-awg/routes.txt
set -uo pipefail
LABEL="snapshot"
ENDPOINT=""
while [[ $# -gt 0 ]]; do
case "$1" in
--label) LABEL="$2"; shift 2 ;;
--label=*) LABEL="${1#*=}"; shift ;;
--endpoint) ENDPOINT="$2"; shift 2 ;;
--endpoint=*) ENDPOINT="${1#*=}"; shift ;;
-h|--help)
grep '^#' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
OUT="$(pwd)/snapshot-${LABEL}"
mkdir -p "$OUT"
IS_ROOT=$([ "$(id -u)" -eq 0 ] && echo true || echo false)
have() { command -v "$1" >/dev/null 2>&1; }
# Runs block function $2, writes a timestamped header plus its output
# (stdout+stderr) to $OUT/$1.txt. Errors inside a block don't abort the script.
save() {
local name="$1" fn="$2" path
path="$OUT/$name.txt"
{
echo "=== $name === $(date -Iseconds 2>/dev/null || date) (root=$IS_ROOT)"
"$fn" 2>&1
} > "$path"
echo " [ok] $name"
}
echo "Snapshot '$LABEL' -> $OUT"
[ "$IS_ROOT" = true ] || echo " [warn] not running as root — some data will be incomplete"
# --- 1. System ---------------------------------------------------------
system_block() {
echo "--- sw_vers ---"
sw_vers 2>&1
echo
echo "--- uname / hostname ---"
uname -a
hostname
echo
echo "IsRoot: $IS_ROOT"
}
save "system" system_block
# --- 2. Adapters, including hidden and disabled -----------------------------
# Key for detecting an "overwritten driver" and leftover interfaces.
adapters_block() {
echo "--- ifconfig -a (all interfaces, including down) ---"
ifconfig -a
echo
echo "--- networksetup -listallhardwareports ---"
have networksetup && networksetup -listallhardwareports 2>&1
echo
echo "--- networksetup -listallnetworkservices ---"
have networksetup && networksetup -listallnetworkservices 2>&1
echo
echo "--- leftover tunnel-like interfaces with no active traffic ---"
echo "(utun/awg/wg present but status is not 'active', sign of a stuck previous session)"
for ifc in $(ifconfig -l 2>/dev/null); do
case "$ifc" in
utun*|awg*|wg*)
status=$(ifconfig "$ifc" 2>/dev/null | awk '/status:/{print $2}')
if [ -n "$status" ] && [ "$status" != "active" ]; then
echo "$ifc: status=$status"
fi
;;
esac
done
}
save "adapters" adapters_block
# --- 3. Connection type (cellular / wifi / ethernet) ------------------------
link_type_block() {
echo "--- type detection via ifconfig media / networksetup ---"
for ifc in $(ifconfig -l 2>/dev/null); do
[ "$ifc" = "lo0" ] && continue
t="other"
case "$ifc" in
en*)
if have networksetup && networksetup -getairportnetwork "$ifc" >/dev/null 2>&1; then
t="wifi"
else
t="ethernet-or-wifi"
fi
;;
pdp_ip*|wwan*) t="cellular" ;;
utun*|ppp*|ipsec*) t="vpn-tunnel" ;;
awg*|wg*) t="vpn-tunnel" ;;
esac
status=$(ifconfig "$ifc" 2>/dev/null | awk '/status:/{print $2}')
echo "$ifc: type=$t status=$status"
done
echo
echo "--- current Wi-Fi network (if any) ---"
if have networksetup; then
for ifc in $(ifconfig -l 2>/dev/null); do
case "$ifc" in en*)
networksetup -getairportnetwork "$ifc" 2>&1
;; esac
done
fi
echo
echo "--- scutil --nwi (primary interface / reachability) ---"
have scutil && scutil --nwi 2>&1
echo
echo "--- interface carrying the default route (determines the link type) ---"
dev=$(route -n get default 2>/dev/null | awk '/interface:/{print $2}')
if [ -n "$dev" ]; then
echo "default route dev=$dev"
else
echo "no default route"
fi
}
save "link-type" link_type_block
# --- 4. Drivers: kernel extensions and system network config ----------------
# Catches the WireGuard/tun kext or sysex, and split-tunnel related extensions.
drivers_block() {
echo "--- kextstat (legacy kexts, if any) ---"
have kextstat && kextstat 2>&1
echo
echo "--- systemextensionsctl list (modern DriverKit/NetworkExtension sysexes) ---"
have systemextensionsctl && systemextensionsctl list 2>&1
echo
echo "--- VPN/netfilter related kexts ---"
have kextstat && kextstat 2>&1 | grep -iE 'wireguard|tun|tap|utun|amnezia'
}
save "drivers" drivers_block
# --- 5. Routes + conflict analysis -------------------------------------
routes_block() {
echo "--- netstat -nr (IPv4 + IPv6 routing table) ---"
netstat -nr
echo
echo "--- netstat -nr -f inet (IPv4 only) ---"
netstat -nr -f inet
echo
echo "--- netstat -nr -f inet6 (IPv6 only) ---"
netstat -nr -f inet6
echo
echo "--- default routes only ---"
netstat -nr | awk 'NR==1 || /^default/'
echo
echo "--- interfaces: addresses ---"
ifconfig -a | grep -E '^[a-z]|inet '
echo
echo "--- default route conflicts ---"
echo "macOS auto-creates a link-local (fe80::%ifc) IPv6 'default' entry per utun"
echo "interface regardless of VPN state — that's normal noise, not a conflict."
echo "Amnezia's AWG tunnel also often uses a split route (::/1 + 8000::/1) instead"
echo "of a literal ::/0 default, so only non-link-local default gateways are counted."
for fam in inet inet6; do
[ "$fam" = "inet" ] && label="IPv4" || label="IPv6"
count=$(netstat -nr -f "$fam" 2>/dev/null | awk '/^default/ && $2 !~ /^fe80:/' | wc -l | tr -d ' ')
echo "-- $label: $count non-link-local default route(s) --"
if [ "$count" -gt 1 ]; then
echo "!!! CONFLICT: multiple $label default routes present"
netstat -nr -f "$fam" 2>/dev/null | awk 'NR==1 || (/^default/ && $2 !~ /^fe80:/)'
fi
done
echo
echo "--- private subnet reachability vs tunnel ---"
echo "If a private subnet route prefers the tunnel, LAN (router, NAS, printer) becomes unreachable."
netstat -nr -f inet 2>/dev/null | grep -E '^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)'
}
save "routes" routes_block
# --- 6. Actual routing stack resolution --------------------------------------
# What you can't see in `netstat -nr`: where the packet will ACTUALLY go.
# Also checks the route to the endpoint's own next-hop, since that's where a
# routing loop through the tunnel would actually show up.
route_resolution_block() {
targets=("8.8.8.8" "1.1.1.1")
if [ -n "$ENDPOINT" ]; then targets=("$ENDPOINT" "${targets[@]}"); fi
if [ -n "$ENDPOINT" ]; then
nexthop=$(route -n get "$ENDPOINT" 2>/dev/null | awk '/gateway:/{print $2}')
if [ -n "$nexthop" ]; then targets+=("$nexthop"); fi
fi
for t in "${targets[@]}"; do
echo "--- route -n get $t ---"
route -n get "$t" 2>&1
echo
done
if [ -n "$ENDPOINT" ]; then
echo "--- endpoint reachability ---"
outdev=$(route -n get "$ENDPOINT" 2>/dev/null | awk '/interface:/{print $2}')
if [ -n "$outdev" ]; then
case "$outdev" in
utun*|awg*|wg*|ppp*|ipsec*)
echo "!!! LOOP: traffic to server $ENDPOINT goes through the VIRTUAL/TUNNEL interface '$outdev'"
;;
*)
echo "OK: traffic to server goes through the physical interface '$outdev'"
;;
esac
else
echo "Could not determine the interface."
fi
echo
echo "--- host route to endpoint (anti-loop guard) ---"
route -n get -host "$ENDPOINT" 2>&1
fi
}
save "route-resolution" route_resolution_block
# --- 7. DNS + leak risk assessment -------------------------------------------
dns_block() {
echo "--- scutil --dns (effective per-interface resolver config) ---"
have scutil && scutil --dns 2>&1
echo
echo "--- /etc/resolv.conf (usually managed/symlinked on macOS) ---"
cat /etc/resolv.conf 2>&1
echo
echo "--- networksetup -getdnsservers per network service ---"
if have networksetup; then
while IFS= read -r svc; do
[[ "$svc" == "An asterisk"* ]] && continue
echo "-- $svc --"
networksetup -getdnsservers "$svc" 2>&1
done < <(networksetup -listallnetworkservices 2>/dev/null | tail -n +2)
fi
echo
echo "--- DNS leak risk ---"
tundev=$(route -n get default 2>/dev/null | awk '/interface:/{print $2}')
echo "Default route goes through dev=$tundev"
if have scutil; then
# Each "resolver #N" block may carry an "if_index : N (ifc)" line and one or
# more "nameserver[N] : ..." lines, in either order; scan a whole block before
# deciding whether it counted as "has DNS on interface X".
withdns=$(scutil --dns 2>/dev/null | awk '
/^resolver #/ { ifc=""; hasdns=0 }
/if_index/ { line=$0; sub(/.*\(/, "", line); sub(/\).*/, "", line); ifc=line }
/nameserver\[[0-9]+\]/ { hasdns=1 }
/^$/ { if (ifc != "" && hasdns) print ifc }
END { if (ifc != "" && hasdns) print ifc }')
echo "Interfaces with DNS configured: $(echo "$withdns" | sort -u | grep -c .)"
other=$(echo "$withdns" | sort -u | grep -v "^${tundev}$" | grep -v '^$')
if [ -n "$other" ]; then
echo "!!! LEAK RISK: DNS is configured on more than just the active interface:"
echo "$other"
else
echo "No obvious risk."
fi
fi
}
save "dns" dns_block
# --- 8. IPv6 and localhost ---------------------------------------------
ipv6_localhost_block() {
echo "--- IP addresses (all families) ---"
ifconfig -a | grep -E '^[a-z]|inet '
echo
echo "--- IPv6 disabled? (per-interface sysctl / networksetup) ---"
sysctl net.inet6.ip6.forwarding 2>&1
if have networksetup; then
while IFS= read -r svc; do
[[ "$svc" == "An asterisk"* ]] && continue
echo "-- $svc --"
networksetup -getinfo "$svc" 2>&1 | grep -i ipv6
done < <(networksetup -listallnetworkservices 2>/dev/null | tail -n +2)
fi
echo
echo "--- localhost resolution ---"
dscacheutil -q host -a name localhost 2>&1
echo
echo "--- hosts file ---"
cat /etc/hosts
echo
echo "--- IPv6 leak risk ---"
echo "Sign: a default IPv6 route exists through the tunnel, but the tunnel only has"
echo "ULA (fc00::/7) or link-local addresses - no real IPv6 connectivity."
# macOS auto-creates a link-local (fe80::%ifc) 'default' entry per utun interface
# regardless of VPN state, and a split-tunnel VPN may install ::/1 instead of a
# literal ::/0 default — so skip link-local noise and accept either form.
tun6dev=$(netstat -nr -f inet6 2>/dev/null | awk '(/^default/ || /^::\/1/) && $2 !~ /^fe80:/{print $NF; exit}')
if [ -n "$tun6dev" ]; then
echo "IPv6 default route goes through dev=$tun6dev"
addrs=$(ifconfig "$tun6dev" 2>/dev/null | awk '/inet6/{print $2}')
echo "$addrs"
global=$(echo "$addrs" | grep -vE '^(fe80|fc|fd)')
if [ -z "$global" ]; then
echo "!!! Only ULA/link-local addresses - real IPv6 traffic will leak outside the tunnel."
fi
else
echo "No IPv6 default route - no IPv6 routing."
fi
}
save "ipv6-localhost" ipv6_localhost_block
# --- 9. Proxy: environment and system settings ------------------------------
# Key block for Xray: it often works through the system proxy.
proxy_block() {
echo "--- environment variables ---"
env | grep -i proxy
echo
echo "--- scutil --proxy (system-wide proxy configuration) ---"
have scutil && scutil --proxy 2>&1
echo
echo "--- networksetup proxy settings per network service ---"
if have networksetup; then
while IFS= read -r svc; do
[[ "$svc" == "An asterisk"* ]] && continue
echo "-- $svc --"
networksetup -getwebproxy "$svc" 2>&1
networksetup -getsecurewebproxy "$svc" 2>&1
networksetup -getsocksfirewallproxy "$svc" 2>&1
done < <(networksetup -listallnetworkservices 2>/dev/null | tail -n +2)
fi
}
save "proxy" proxy_block
# --- 10. VPN/antiDPI services and processes ----------------------------
# Matched by exact process/label name rather than a broad substring, since a
# substring match also pulls in unrelated software installed on the machine.
services_processes_block() {
local -a known_labels=(
org.amnezia.vpn.service com.wireguard.macos
net.openvpn.client mullvad-daemon
com.tailscale.ipn io.tailscale.ipn.macsys
com.nordvpn.osx ch.protonvpn.mac
)
local -a known_procs=(
AmneziaVPN amnezia-service wireguard-go wg-quick openvpn
mullvad-daemon tailscaled nfqws winws zapret goodbyedpi
byedpi ciadpi xray v2ray sing-box tun2socks
)
echo "--- launchctl entries matching known exact labels ---"
if have launchctl; then
for label in "${known_labels[@]}"; do
launchctl list 2>/dev/null | awk -v l="$label" '$3==l'
done
fi
echo
echo "--- processes matching known exact names ---"
for p in "${known_procs[@]}"; do
pgrep -l -x "$p" 2>/dev/null
done
echo
echo "--- listening TCP sockets belonging to those processes ---"
if have lsof; then
for p in "${known_procs[@]}"; do
lsof -nP -iTCP -sTCP:LISTEN -c "$p" 2>/dev/null
done
fi
echo
echo "--- UDP endpoints belonging to those processes ---"
if have lsof; then
for p in "${known_procs[@]}"; do
lsof -nP -iUDP -c "$p" 2>/dev/null
done
fi
echo
echo "--- full listening socket list (unfiltered, for reference) ---"
if have lsof; then
lsof -nP -iTCP -sTCP:LISTEN 2>&1
lsof -nP -iUDP 2>&1
else
netstat -anp tcp 2>&1
netstat -anp udp 2>&1
fi
}
save "services-processes" services_processes_block
# --- 11. Firewall / packet filter ------------------------------------------------
# Amnezia loads its rules under the pf anchor "amn" (see macosfirewall.cpp).
firewall_block() {
echo "--- pfctl -s info (pf enabled/disabled, stats) ---"
have pfctl && pfctl -s info 2>&1
echo
echo "--- pfctl -s Anchors (all loaded anchors) ---"
have pfctl && pfctl -s Anchors 2>&1
echo
echo "--- pfctl -a amn -s rules (Amnezia's own anchor + sub-anchors) ---"
if have pfctl; then
anchors=$(pfctl -s Anchors 2>/dev/null | awk '/^amn/ {sub(/\*$/, "", $1); print $1}')
for anc in $anchors; do
echo "-- anchor: $anc --"
pfctl -a "$anc" -s rules 2>&1
done
fi
echo
echo "--- pfctl -s rules (rules in the main ruleset, not under any anchor) ---"
have pfctl && pfctl -s rules 2>&1
echo
echo "--- socketfilterfw (Application Firewall) ---"
if [ -x /usr/libexec/ApplicationFirewall/socketfilterfw ]; then
/usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate 2>&1
/usr/libexec/ApplicationFirewall/socketfilterfw --listapps 2>&1
fi
}
save "firewall" firewall_block
echo
echo "Done. Folder: $OUT"
echo "Diff of two runs, e.g.:"
echo " diff snapshot-clean/routes.txt snapshot-awg/routes.txt"
@@ -0,0 +1,427 @@
# network-diagnostics-windows.ps1
# Windows network state snapshot for establishing a baseline.
# Run in PowerShell AS ADMINISTRATOR.
#
# Usage (three runs):
# .\network-diagnostics-windows.ps1 -Label clean
# .\network-diagnostics-windows.ps1 -Label awg -Endpoint <server IP>
# .\network-diagnostics-windows.ps1 -Label xray -Endpoint <server IP>
#
# Then compare the folders, e.g.:
# Compare-Object (gc .\snapshot-clean\routes.txt) (gc .\snapshot-awg\routes.txt)
param(
[string]$Label = "snapshot",
[string]$Endpoint = ""
)
$out = Join-Path (Get-Location) "snapshot-$Label"
New-Item -ItemType Directory -Path $out -Force | Out-Null
$isAdmin = ([Security.Principal.WindowsPrincipal]`
[Security.Principal.WindowsIdentity]::GetCurrent()`
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Warning "Running WITHOUT administrator rights. WFP filters and some driver data will not be collected."
}
function Save($name, $block) {
$path = Join-Path $out "$name.txt"
"=== $name === $(Get-Date -Format o) (admin=$isAdmin)" | Out-File $path -Encoding utf8
try { & $block 2>&1 | Out-String -Width 500 | Out-File $path -Append -Encoding utf8 }
catch { "ERROR: $($_.Exception.Message)" | Out-File $path -Append -Encoding utf8 }
Write-Host " [ok] $name"
}
function Has($cmd) { [bool](Get-Command $cmd -ErrorAction SilentlyContinue) }
Write-Host "Snapshot '$Label' -> $out"
# --- 1. System ---------------------------------------------------------
Save "system" {
Get-ComputerInfo -Property OsName, OsVersion, OsBuildNumber, CsName |
Format-List
"IsAdmin : $isAdmin"
"PSVersion : $($PSVersionTable.PSVersion)"
}
# --- 2. Adapters, including hidden and disabled -----------------------------
# Key for detecting an "overwritten driver" and leftover interfaces.
Save "adapters" {
Get-NetAdapter -IncludeHidden |
Select-Object Name, InterfaceDescription, ifIndex, Status, AdminStatus,
MacAddress, DriverProvider, DriverVersion, DriverDate,
DriverFileName, Virtual, Hidden |
Format-List
"`n--- leftover virtual adapters ---"
"(virtual, not hidden, AdminStatus=Up but Status != Up)"
Get-NetAdapter -IncludeHidden |
Where-Object { $_.Virtual -and -not $_.Hidden -and
$_.AdminStatus -eq 'Up' -and $_.Status -ne 'Up' } |
Select-Object Name, InterfaceDescription, ifIndex, Status,
DriverProvider, DriverFileName |
Format-Table -AutoSize
"`n--- APIPA / deprecated addresses (sign of a stuck interface) ---"
Get-NetIPAddress -ErrorAction SilentlyContinue |
Where-Object { $_.IPAddress -like '169.254.*' -or
$_.AddressState -in 'Tentative','Deprecated','Invalid' } |
Select-Object InterfaceAlias, ifIndex, IPAddress, AddressState |
Format-Table -AutoSize
}
# --- 3. Connection type (cellular / wifi / ethernet) ------------------------
Save "link-type" {
$medium = @{
0='Unspecified'; 1='Wireless'; 2='CableModem'; 3='PhoneLine'; 4='PowerLine'
5='DSL'; 6='FibreChannel'; 7='1394'; 8='WirelessWan (CELLULAR)'
9='Native802.11 (WIFI)'; 10='Bluetooth'; 11='Infiniband'; 12='WiMax'
13='UWB'; 14='802.3 (ETHERNET)'; 15='802.5'; 16='Irda'; 17='WiredWan'
18='WiredCoWanDevice'; 19='Other'
}
Get-NetAdapter -IncludeHidden |
Select-Object Name, ifIndex, InterfaceType, NdisPhysicalMedium,
@{n='MediumName';e={ $medium[[int]$_.NdisPhysicalMedium] }},
MediaConnectionState, Virtual |
Format-Table -AutoSize
"`n--- interface carrying the default route (determines the link type) ---"
Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue |
ForEach-Object {
$a = Get-NetAdapter -InterfaceIndex $_.ifIndex -ErrorAction SilentlyContinue
[pscustomobject]@{
ifIndex = $_.ifIndex
Alias = $_.InterfaceAlias
NextHop = $_.NextHop
TotalMetric = $_.RouteMetric + $_.InterfaceMetric
Medium = if ($a) { $medium[[int]$a.NdisPhysicalMedium] } else { 'n/a' }
Virtual = if ($a) { $a.Virtual } else { 'n/a' }
}
} | Sort-Object TotalMetric | Format-Table -AutoSize
"`n--- Connection profiles ---"
Get-NetConnectionProfile | Select-Object Name, InterfaceAlias, NetworkCategory,
IPv4Connectivity, IPv6Connectivity |
Format-List
}
# --- 4. Drivers: network filters and tunnels --------------------------------
# Catches WinDivert-style filters, TAP/Wintun, and split-tunnel drivers.
# Matched by exact driver/service name rather than a substring, since a
# substring like "tap" also matches unrelated services (TapiSrv, EntAppSvc,
# XboxNetApiSvc on stock Windows).
Save "drivers-known" {
$known = @{
'wintun' = 'Wintun (WireGuard/Amnezia/tun2socks)'
'wireguard' = 'WireGuard NT'
'tap0901' = 'TAP-Windows (OpenVPN)'
'tapwindscribe0901' = 'Windscribe (TAP)'
'windtun420' = 'Windscribe (Wintun)'
'tapnordvpn' = 'NordVPN (TAP)'
'tapprotonvpn' = 'ProtonVPN (TAP)'
'mullvad-split-tunnel' = 'Mullvad split tunnel'
'mullvad-wireguard' = 'Mullvad WireGuard'
'Tailscale' = 'Tailscale'
'WinDivert' = 'WinDivert (used by DPI circumvention tools)'
'WinDivert1.4' = 'WinDivert 1.4'
'adgnetworkwfpdrv' = 'AdGuard (WFP callout)'
'adguardsvc' = 'AdGuard service'
}
"--- exact-name matches ---"
Get-CimInstance Win32_SystemDriver -ErrorAction SilentlyContinue |
Where-Object { $known.ContainsKey($_.Name) } |
Select-Object Name, @{n='Identified';e={ $known[$_.Name] }},
State, StartMode, PathName |
Format-Table -AutoSize
"`n--- all WFP callout / NDIS filter drivers (traffic interception) ---"
Get-CimInstance Win32_SystemDriver -ErrorAction SilentlyContinue |
Where-Object { $_.PathName -match 'drivers' -and
$_.Name -match 'wfp|ndis|filter|divert|tun|tap' } |
Select-Object Name, DisplayName, State, StartMode |
Sort-Object Name | Format-Table -AutoSize
}
Save "drivers-store" {
if ($isAdmin) { pnputil /enum-drivers } else { "Requires administrator rights." }
}
Save "drivers-net-class" {
Get-CimInstance Win32_PnPSignedDriver -ErrorAction SilentlyContinue |
Where-Object { $_.DeviceClass -eq 'NET' } |
Select-Object DeviceName, DriverProviderName, DriverVersion,
DriverDate, InfName, IsSigned, Signer |
Sort-Object DriverProviderName | Format-List
"`n--- network driver providers (grouping by Provider is more reliable than by inf name) ---"
"e.g. Windscribe ships its TAP driver under oemvista.inf, so the inf name alone is not reliable."
Get-CimInstance Win32_PnPSignedDriver -ErrorAction SilentlyContinue |
Where-Object { $_.DeviceClass -eq 'NET' } |
Group-Object DriverProviderName |
Select-Object Count, Name | Format-Table -AutoSize
}
# --- 5. Routes + conflict analysis on TOTAL metric --------------------------
Save "routes" {
"--- route print ---"
route print
"`n--- Get-NetRoute with total metric ---"
Get-NetRoute -ErrorAction SilentlyContinue |
Select-Object AddressFamily, DestinationPrefix, NextHop, ifIndex,
InterfaceAlias, RouteMetric, InterfaceMetric,
@{n='TotalMetric';e={ $_.RouteMetric + $_.InterfaceMetric }},
Protocol |
Sort-Object AddressFamily, DestinationPrefix, TotalMetric |
Format-Table -AutoSize
"`n--- default route conflicts ---"
"Amnezia's norm: two default routes, the tunnel wins on the LOWER interface metric."
"Conflict: equal total metrics between the tunnel and another default route."
foreach ($p in @('0.0.0.0/0','::/0')) {
"--- $p ---"
$rs = Get-NetRoute -DestinationPrefix $p -ErrorAction SilentlyContinue |
Select-Object ifIndex, InterfaceAlias, NextHop,
@{n='TotalMetric';e={ $_.RouteMetric + $_.InterfaceMetric }} |
Sort-Object TotalMetric
$rs | Format-Table -AutoSize
if ($rs.Count -gt 1) {
$g = $rs | Group-Object TotalMetric | Where-Object { $_.Count -gt 1 }
if ($g) { "!!! CONFLICT: multiple default routes with equal metric $($g.Name)" }
else { "OK: winner is $($rs[0].InterfaceAlias) (metric $($rs[0].TotalMetric))" }
}
}
"`n--- private subnet reachability vs tunnel ---"
"If a private subnet route prefers the tunnel, LAN (router, NAS, printer) becomes unreachable."
Get-NetRoute -ErrorAction SilentlyContinue |
Where-Object { $_.DestinationPrefix -match '^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)' } |
Select-Object DestinationPrefix, ifIndex, InterfaceAlias,
@{n='TotalMetric';e={ $_.RouteMetric + $_.InterfaceMetric }}, Protocol |
Sort-Object DestinationPrefix, TotalMetric | Format-Table -AutoSize
"`n--- interface metrics ---"
Get-NetIPInterface | Select-Object ifIndex, InterfaceAlias, AddressFamily,
InterfaceMetric, AutomaticMetric, Dhcp,
ConnectionState |
Sort-Object ifIndex | Format-Table -AutoSize
}
# --- 6. Actual routing stack resolution --------------------------------------
# What you can't see in route print: where the packet will ACTUALLY go.
# Also checks the route to the endpoint's own next-hop, since that's where a
# routing loop through the tunnel would actually show up.
Save "route-resolution" {
$targets = @('8.8.8.8','1.1.1.1')
if ($Endpoint) { $targets = @($Endpoint) + $targets }
if ($Endpoint) {
$ep = Find-NetRoute -RemoteIPAddress $Endpoint -ErrorAction SilentlyContinue |
Where-Object { $_.NextHop -and $_.NextHop -ne '0.0.0.0' } |
Select-Object -First 1
if ($ep) { $targets += $ep.NextHop }
}
foreach ($t in ($targets | Select-Object -Unique)) {
"=== Find-NetRoute -RemoteIPAddress $t ==="
Find-NetRoute -RemoteIPAddress $t -ErrorAction SilentlyContinue |
Select-Object IPAddress, InterfaceAlias, ifIndex,
DestinationPrefix, NextHop, RouteMetric |
Format-List
""
}
if ($Endpoint) {
"=== endpoint reachability ==="
$r = Find-NetRoute -RemoteIPAddress $Endpoint -ErrorAction SilentlyContinue |
Select-Object -First 1
$a = if ($r) { Get-NetAdapter -InterfaceIndex $r.ifIndex -ErrorAction SilentlyContinue }
if ($a -and $a.Virtual) {
"!!! LOOP: traffic to server $Endpoint goes through the VIRTUAL/TUNNEL interface '$($a.Name)'"
} elseif ($a) {
"OK: traffic to server goes through the physical interface '$($a.Name)'"
} else {
"Could not determine the interface."
}
"`n--- host route /32 to endpoint (anti-loop guard) ---"
Get-NetRoute -DestinationPrefix "$Endpoint/32" -ErrorAction SilentlyContinue |
Select-Object DestinationPrefix, NextHop, ifIndex, InterfaceAlias,
RouteMetric, InterfaceMetric | Format-Table -AutoSize
}
}
# --- 7. DNS + leak risk assessment -------------------------------------------
Save "dns" {
"--- configured servers (only interfaces that have any) ---"
Get-DnsClientServerAddress -ErrorAction SilentlyContinue |
Where-Object { $_.ServerAddresses.Count -gt 0 } |
Select-Object InterfaceAlias, InterfaceIndex,
@{n='Family';e={ if ($_.AddressFamily -eq 2) {'IPv4'} else {'IPv6'} }},
@{n='Servers';e={ $_.ServerAddresses -join ', ' }} |
Format-Table -AutoSize
"`n--- global settings ---"
Get-DnsClientGlobalSetting | Format-List
"`n--- NRPT rules (VPN clients write here to bind DNS to the tunnel) ---"
$nrpt = Get-DnsClientNrptRule -ErrorAction SilentlyContinue
if ($nrpt) { $nrpt | Format-List } else { "No NRPT rules." }
"`n--- DoH configuration (cmdlet not present on every build) ---"
if (Has 'Get-DnsClientDohServerAddress') {
Get-DnsClientDohServerAddress | Format-Table -AutoSize
} else { "Get-DnsClientDohServerAddress is not available on this Windows build." }
"`n--- DNS leak risk ---"
$tunIdx = Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue |
Sort-Object { $_.RouteMetric + $_.InterfaceMetric } |
Select-Object -First 1 -ExpandProperty ifIndex
$withDns = Get-DnsClientServerAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.ServerAddresses.Count -gt 0 }
"Default route goes through ifIndex=$tunIdx"
"Interfaces with DNS configured: $($withDns.Count)"
$other = $withDns | Where-Object { $_.InterfaceIndex -ne $tunIdx }
if ($other -and -not $nrpt) {
"!!! LEAK RISK: DNS is configured on more than just the active interface, and there are no NRPT rules."
$other | Select-Object InterfaceAlias, InterfaceIndex,
@{n='Servers';e={ $_.ServerAddresses -join ', ' }} |
Format-Table -AutoSize
} else { "No obvious risk." }
"`n--- Smart multi-homed name resolution (policy) ---"
Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\DNSClient' `
-ErrorAction SilentlyContinue |
Select-Object DisableSmartNameResolution, DisableParallelAandAAAA | Format-List
}
# --- 8. IPv6 and localhost ---------------------------------------------
Save "ipv6-localhost" {
"--- IP addresses ---"
Get-NetIPAddress -ErrorAction SilentlyContinue |
Select-Object InterfaceAlias, ifIndex, IPAddress, PrefixLength,
@{n='Family';e={ $_.AddressFamily }}, AddressState, SkipAsSource |
Sort-Object Family, InterfaceAlias | Format-Table -AutoSize
"`n--- DisabledComponents (0=enabled, 8=prefer IPv4, 255=disabled, 32=...) ---"
Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters' `
-Name DisabledComponents -ErrorAction SilentlyContinue |
Select-Object DisabledComponents | Format-List
"`n--- IPv6 leak risk ---"
"Sign: ::/0 goes through the tunnel, but the tunnel only has ULA (fc00::/7) addresses - no real connectivity."
$v6def = Get-NetRoute -DestinationPrefix '::/0' -ErrorAction SilentlyContinue |
Sort-Object { $_.RouteMetric + $_.InterfaceMetric } | Select-Object -First 1
if ($v6def) {
"::/0 goes through '$($v6def.InterfaceAlias)' (ifIndex $($v6def.ifIndex))"
$addrs = Get-NetIPAddress -InterfaceIndex $v6def.ifIndex -AddressFamily IPv6 `
-ErrorAction SilentlyContinue
$addrs | Select-Object IPAddress, PrefixLength, AddressState | Format-Table -AutoSize
$global = $addrs | Where-Object {
$_.IPAddress -notmatch '^fe80|^fc|^fd' }
if (-not $global) {
"!!! Only ULA/link-local addresses - real IPv6 traffic will leak outside the tunnel."
}
Get-NetConnectionProfile -InterfaceIndex $v6def.ifIndex -ErrorAction SilentlyContinue |
Select-Object InterfaceAlias, IPv6Connectivity | Format-List
} else { "::/0 is absent - no IPv6 routing." }
"`n--- localhost resolution ---"
Resolve-DnsName localhost -ErrorAction SilentlyContinue | Format-Table -AutoSize
"`n--- hosts file (only non-empty, non-commented lines) ---"
Get-Content "$env:SystemRoot\System32\drivers\etc\hosts" -ErrorAction SilentlyContinue |
Where-Object { $_ -match '\S' -and $_ -notmatch '^\s*#' }
}
# --- 9. Proxy: system and browser (WinINET) ----------------------------
# Key block for Xray: it often works through the system proxy.
Save "proxy" {
"--- env vars ---"
Get-ChildItem Env: | Where-Object { $_.Name -match 'PROXY' } | Format-Table -AutoSize
"`n--- WinINET (HKCU, what the browser sees) ---"
Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' `
-ErrorAction SilentlyContinue |
Select-Object ProxyEnable, ProxyServer, ProxyOverride, AutoConfigURL | Format-List
"`n--- WinHTTP ---"
netsh winhttp show proxy
}
# --- 10. VPN/antiDPI services and processes ----------------------------
# Matched by exact name rather than a broad substring, since a substring
# match also pulls in unrelated software installed on the machine.
Save "services-processes" {
$svc = @('AmneziaVPN-service','wintun','windtun420','tapwindscribe0901',
'tap0901','Tailscale','WinDivert','WinDivert1.4','adgnetworkwfpdrv',
'adguardsvc','mullvad-split-tunnel','mullvad-wireguard','MullvadVPN',
'NordVPN','ProtonVPNService','OpenVPNService','OpenVPNServiceInteractive')
"--- services matching known exact names (+ per-tunnel Amnezia services) ---"
Get-Service -ErrorAction SilentlyContinue |
Where-Object { $svc -contains $_.Name -or $_.Name -like 'AmneziaWGTunnel$*' } |
Select-Object Name, DisplayName, Status, StartType | Format-Table -AutoSize
$proc = @('AmneziaVPN','AmneziaVPN-service','wireguard','openvpn','mullvad-daemon',
'tailscaled','nfqws','winws','goodbyedpi','byedpi','ciadpi',
'xray','v2ray','sing-box','tun2socks','clash-verge','Windscribe')
"`n--- processes matching known exact names ---"
Get-Process -ErrorAction SilentlyContinue |
Where-Object { $proc -contains $_.ProcessName } |
Select-Object ProcessName, Id, Path | Format-Table -AutoSize
"`n--- listening sockets belonging to those processes ---"
Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue |
ForEach-Object {
$p = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue
if ($p -and $proc -contains $p.ProcessName) {
[pscustomobject]@{ Addr=$_.LocalAddress; Port=$_.LocalPort; Process=$p.ProcessName }
}
} | Sort-Object Port | Format-Table -AutoSize
"`n--- UDP endpoints belonging to those processes ---"
Get-NetUDPEndpoint -ErrorAction SilentlyContinue |
ForEach-Object {
$p = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue
if ($p -and $proc -contains $p.ProcessName) {
[pscustomobject]@{ Addr=$_.LocalAddress; Port=$_.LocalPort; Process=$p.ProcessName }
}
} | Sort-Object Port | Format-Table -AutoSize
}
# --- 11. Firewall / WFP -----------------------------------------------------
Save "firewall" {
Get-NetFirewallProfile | Select-Object Name, Enabled, DefaultInboundAction,
DefaultOutboundAction | Format-Table -AutoSize
"`n--- enabled outbound block rules ---"
Get-NetFirewallRule -Enabled True -Direction Outbound -Action Block `
-ErrorAction SilentlyContinue |
Select-Object DisplayName, Profile, Owner | Format-Table -AutoSize
"`n--- third-party security products ---"
Get-CimInstance -Namespace root\SecurityCenter2 -ClassName AntiVirusProduct `
-ErrorAction SilentlyContinue |
Select-Object displayName, productState, pathToSignedProductExe | Format-List
Get-CimInstance -Namespace root\SecurityCenter2 -ClassName FirewallProduct `
-ErrorAction SilentlyContinue | Select-Object displayName | Format-List
}
# --- 12. WFP filters (heavy output, separate file) --------------------------
Save "wfp-filters" {
if (-not $isAdmin) { "Requires administrator rights (would be ACCESS_DENIED otherwise)." }
else {
$tmp = Join-Path $env:TEMP "wfp-$Label.xml"
netsh wfp show filters file="$tmp" 2>&1
if (Test-Path $tmp) {
$x = [xml](Get-Content $tmp)
"Total filters: $($x.wfpdiag.filters.item.Count)"
"`n--- filter providers (who intercepts traffic) ---"
$x.wfpdiag.filters.item |
Group-Object { $_.displayData.name } |
Sort-Object Count -Descending |
Select-Object -First 40 Count, Name | Format-Table -AutoSize
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
}
}
}
Write-Host "`nDone. Folder: $out"
Write-Host "Run three times: clean (VPN off) / awg / xray, then compare."
@@ -0,0 +1,7 @@
<RCC>
<qresource prefix="/network_diagnostics">
<file>network-diagnostics-linux.sh</file>
<file>network-diagnostics-macos.sh</file>
<file>network-diagnostics-windows.ps1</file>
</qresource>
</RCC>
+101
View File
@@ -0,0 +1,101 @@
#include "networkdiagnostics.h"
#include <QDir>
#include <QFile>
#include <QProcess>
#include <QTemporaryDir>
#include <QTextStream>
namespace {
constexpr int kProcessTimeoutMsec = 30000;
const QString kSnapshotLabel = QStringLiteral("diagnostics");
#if defined(Q_OS_WIN)
const QStringList kSectionOrder = { "system", "adapters", "link-type",
"drivers-known", "drivers-store", "drivers-net-class",
"routes", "route-resolution", "dns",
"ipv6-localhost", "proxy", "services-processes",
"firewall", "wfp-filters" };
#elif defined(Q_OS_LINUX)
const QStringList kSectionOrder = { "system", "adapters", "link-type", "drivers",
"routes", "route-resolution", "dns", "ipv6-localhost",
"proxy", "services-processes", "firewall", "netfilter-full" };
#elif defined(Q_OS_MACOS)
const QStringList kSectionOrder = { "system", "adapters", "link-type", "drivers",
"routes", "route-resolution", "dns", "ipv6-localhost",
"proxy", "services-processes", "firewall" };
#endif
}
QString NetworkDiagnostics::run()
{
#if !defined(Q_OS_WIN) && !defined(Q_OS_LINUX) && !defined(Q_OS_MACOS)
return QStringLiteral("ERROR: network diagnostics is not supported on this platform");
#else
QTemporaryDir tempDir;
if (!tempDir.isValid()) {
return QStringLiteral("ERROR: failed to create secure temp directory");
}
#if defined(Q_OS_WIN)
const QString resourcePath = QStringLiteral(":/network_diagnostics/network-diagnostics-windows.ps1");
const QString scriptPath = tempDir.filePath(QStringLiteral("network-diagnostics-windows.ps1"));
#elif defined(Q_OS_MACOS)
const QString resourcePath = QStringLiteral(":/network_diagnostics/network-diagnostics-macos.sh");
const QString scriptPath = tempDir.filePath(QStringLiteral("network-diagnostics-macos.sh"));
#else
const QString resourcePath = QStringLiteral(":/network_diagnostics/network-diagnostics-linux.sh");
const QString scriptPath = tempDir.filePath(QStringLiteral("network-diagnostics-linux.sh"));
#endif
if (!QFile::copy(resourcePath, scriptPath)) {
return QStringLiteral("ERROR: failed to extract diagnostics script");
}
QProcess process;
process.setWorkingDirectory(tempDir.path()); // scripts write ./snapshot-<label>/ relative to CWD
#if defined(Q_OS_WIN)
process.start(QStringLiteral("powershell"),
{ "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden",
"-File", scriptPath, "-Label", kSnapshotLabel });
#else
process.start(QStringLiteral("/bin/bash"), { scriptPath, "--label", kSnapshotLabel });
#endif
if (!process.waitForStarted(5000)) {
return QStringLiteral("ERROR: failed to start diagnostics script (%1)").arg(process.errorString());
}
if (!process.waitForFinished(kProcessTimeoutMsec)) {
process.kill();
process.waitForFinished(3000);
return QStringLiteral("ERROR: diagnostics script timed out after %1 s").arg(kProcessTimeoutMsec / 1000);
}
const QString snapshotDir = tempDir.filePath(QStringLiteral("snapshot-%1").arg(kSnapshotLabel));
QString combined;
QTextStream out(&combined);
int sectionsFound = 0;
for (const QString &section : kSectionOrder) {
QFile sectionFile(QStringLiteral("%1/%2.txt").arg(snapshotDir, section));
if (!sectionFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
out << "=== " << section << " === (section missing)\n\n";
continue;
}
QString sectionText = QString::fromUtf8(sectionFile.readAll());
if (sectionText.startsWith(QChar(0xFEFF))) {
sectionText.remove(0, 1);
}
out << sectionText << "\n";
sectionsFound++;
}
if (sectionsFound == 0) {
return QStringLiteral("ERROR: diagnostics script produced no output (exit code %1)").arg(process.exitCode());
}
return combined;
#endif
}
+15
View File
@@ -0,0 +1,15 @@
#ifndef NETWORKDIAGNOSTICS_H
#define NETWORKDIAGNOSTICS_H
#include <QString>
// Runs the bundled per-platform network diagnostics script (extracted from a
// Qt resource into a securely-created temp dir) and returns its concatenated
// section output, or an "ERROR: ..." string on failure.
class NetworkDiagnostics
{
public:
static QString run();
};
#endif // NETWORKDIAGNOSTICS_H