Skip to content

Commit 6301b74

Browse files
anyakushinsfionov
andauthored
TRUST-609 Add ability to clear logs for adapters (PR 43)
* Add clearLogs for the adapters * Integrate clearLogs into testapp * Fix test * Update changelog * Allow to clean netext logs on the fly * On the fly logs cleanup for testapp * Update CHANGELOG.md --------- Co-authored-by: Sergey Fionov <sfionov@adguard.com>
1 parent 0c3468d commit 6301b74

21 files changed

Lines changed: 365 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
88

99
### Added
1010

11+
- Add `clearLogs` method for platform adapters.
12+
1113
### Changed
1214

1315
### Deprecated

platform/android/lib/src/main/java/com/adguard/trusttunnel/VpnService.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,16 @@ class VpnService : android.net.VpnService(), VpnClientListener {
176176

177177
return fileLogger?.snapshotTo(exportDir) ?: emptyList()
178178
}
179+
180+
/**
181+
* Delete all log files produced by the VPN service.
182+
*
183+
* Safe to call while the VPN is active.
184+
*/
185+
fun clearLogs() {
186+
if (!initialized) return
187+
fileLogger?.clearLogs()
188+
}
179189
}
180190

181191
private var state = State.Stopped

platform/android/lib/src/main/java/com/adguard/trusttunnel/log/FileLogger.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,24 @@ class FileLogger(
8686
return future.get()
8787
}
8888

89+
/**
90+
* Delete all log files managed by this logger and reopen a fresh current file.
91+
*
92+
* Safe to call without additional synchronization.
93+
*/
94+
fun clearLogs() {
95+
val future = writeExecutor.submit(Callable<Unit> {
96+
closeFile()
97+
for (idx in 0..archiveCount) {
98+
val file = if (idx == 0) File(directory, "$baseName.log")
99+
else File(directory, "$baseName.$idx.log")
100+
file.delete()
101+
}
102+
openOrCreateFile()
103+
})
104+
future.get()
105+
}
106+
89107
// ---- private ----
90108

91109
/** Lock-free append (runs on single-thread executor). */

platform/android/lib/src/test/java/com/adguard/trusttunnel/log/FileLoggerTest.kt

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.adguard.trusttunnel.log
33
import com.adguard.trusttunnel.Logger
44
import org.junit.After
55
import org.junit.Assert.assertEquals
6+
import org.junit.Assert.assertFalse
67
import org.junit.Assert.assertTrue
78
import org.junit.Before
89
import org.junit.Test
@@ -211,4 +212,60 @@ class FileLoggerTest {
211212
assertTrue("Should include current log", archiveNames.contains("snaparchive.log"))
212213
assertTrue("Should include archive log", archiveNames.contains("snaparchive.1.log"))
213214
}
215+
216+
// ---- clearLogs ----
217+
218+
@Test
219+
fun clearLogs_removesCurrentAndArchiveFiles() {
220+
val logger = FileLogger(logDir, "clear", maxFileSize = 200)
221+
logger.install()
222+
logger.snapshotTo(File(tempDir, "drain"))
223+
224+
// Trigger a rotation so both current and archive files exist.
225+
repeat(5) {
226+
Logger.dispatchNative(Logger.LogLevel.INFO, "line $it")
227+
}
228+
logger.snapshotTo(File(tempDir, "drain2"))
229+
230+
val current = File(logDir, "clear.log")
231+
val archive = File(logDir, "clear.1.log")
232+
assertTrue("Current file should exist before clear", current.exists())
233+
assertTrue("Current file is not empty", current.length().toInt() != 0)
234+
assertTrue("Archive file should exist before clear", archive.exists())
235+
assertTrue("Archive file is not empty", archive.length().toInt() != 0)
236+
237+
logger.clearLogs()
238+
239+
// `clearLogs` reopens the file so logging could proceed after
240+
assertTrue("Current file exists", current.exists())
241+
assertEquals("Current file is empty (clean), ${current.length()}", current.length(), 0)
242+
assertFalse("Archive file should be removed", archive.exists())
243+
}
244+
245+
@Test
246+
fun clearLogs_resetsWriterForNewOutput() {
247+
val logger = FileLogger(logDir, "reset", maxFileSize = 200)
248+
logger.install()
249+
logger.snapshotTo(File(tempDir, "drain"))
250+
251+
repeat(5) {
252+
Logger.dispatchNative(Logger.LogLevel.INFO, "before clear $it")
253+
}
254+
logger.snapshotTo(File(tempDir, "drain2"))
255+
256+
val current = File(logDir, "reset.log")
257+
assertTrue("Current file should have content before clear", current.length() > 0)
258+
259+
logger.clearLogs()
260+
261+
// New writes must land in a freshly created (empty) file.
262+
Logger.dispatchNative(Logger.LogLevel.INFO, "after clear")
263+
logger.snapshotTo(File(tempDir, "drain3"))
264+
265+
assertTrue("Current file should exist after clear", current.exists())
266+
val content = current.readText()
267+
assertFalse("Old content should be gone", content.contains("before clear"))
268+
assertTrue("New content should be present", content.contains("after clear"))
269+
assertFalse("Archive should remain removed", File(logDir, "reset.1.log").exists())
270+
}
214271
}

platform/apple/TrustTunnelClient/FileLogger.swift

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,19 @@ public final class FileLogger {
6565
}
6666
}
6767

68+
/// Clear this logger's own log files and resume writing to a fresh file.
69+
///
70+
/// Runs on the serial `queue` and closes/reopens the handle around the
71+
/// delete, so pending appends drain first and new ones land in a fresh
72+
/// file — nothing is dropped.
73+
public func clearLogs() {
74+
queue.sync {
75+
closeFile()
76+
Self.clearLogs(directory: directory, baseName: baseName, archiveCount: archiveCount)
77+
openOrCreateFile()
78+
}
79+
}
80+
6881
/// Produce point-in-time copies of the `<baseName>.log` family in
6982
/// `directory` into `destDir`, without needing a live `FileLogger`
7083
/// instance for them.
@@ -114,6 +127,44 @@ public final class FileLogger {
114127
return result
115128
}
116129

130+
/// Delete the `baseName`.log / `baseName`.{1…n}.log files in `directory`.
131+
///
132+
/// - Note: Does not synchronize cross-process reads/writes. A process
133+
/// holding the file open keeps writing to the deleted (unlinked) file.
134+
/// For files this process is writing, use the instance `clearLogs()`.
135+
public static func clearLogs(directory: URL,
136+
baseName: String,
137+
archiveCount: Int = 1) {
138+
let fileManager = FileManager.default
139+
let fileCoordinator = NSFileCoordinator(filePresenter: nil)
140+
141+
let candidates = (0 ... archiveCount).map { idx -> URL in
142+
idx == 0
143+
? directory.appendingPathComponent("\(baseName).log")
144+
: directory.appendingPathComponent("\(baseName).\(idx).log")
145+
}
146+
147+
for url in candidates {
148+
var coordinatorError: NSError?
149+
fileCoordinator.coordinate(writingItemAt: url,
150+
options: .forDeleting,
151+
error: &coordinatorError)
152+
{ (actualURL) in
153+
do {
154+
try fileManager.removeItem(at: actualURL)
155+
} catch let error as NSError where error.code == NSFileNoSuchFileError {
156+
// A missing file is expected (e.g. a process that never ran).
157+
} catch {
158+
// Surface genuinely unexpected failures.
159+
Self.fallbackLogger.debug("FileLogger clearLogs skipped \(url.lastPathComponent): \(error.localizedDescription)")
160+
}
161+
}
162+
if let error = coordinatorError {
163+
Self.fallbackLogger.debug("FileLogger clearLogs skipped \(url.lastPathComponent): \(error.localizedDescription)")
164+
}
165+
}
166+
}
167+
117168
/// Resolve the `logs/` directory inside the App Group container.
118169
///
119170
/// Returns `nil` if the app group identifier yields no container URL.

platform/apple/TrustTunnelClient/PacketTunnelProvider.swift

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ open class AGPacketTunnelProvider: NEPacketTunnelProvider {
4747
} else {
4848
self.fileLogger = nil
4949
}
50+
// Register the app's clear-logs request handler.
51+
if !self.bundleIdentifier.isEmpty {
52+
self.setupClearLogsListener()
53+
}
5054
if (config == nil) {
5155
completionHandler(TunnelError.parse_config_failed)
5256
return
@@ -152,6 +156,7 @@ open class AGPacketTunnelProvider: NEPacketTunnelProvider {
152156

153157
open override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
154158
self.clientQueue.async {
159+
self.stopClearLogsListener()
155160
self.stopVpnClient()
156161
completionHandler()
157162
}
@@ -229,4 +234,48 @@ open class AGPacketTunnelProvider: NEPacketTunnelProvider {
229234
)
230235
logger.debug("notifyAppOnConnectionInfo done")
231236
}
237+
238+
// MARK: - Clear logs (App → Network Extension)
239+
240+
/// Registers a Darwin notification observer so the app can ask a running NE
241+
/// to clear its own log file. Idempotent: removes any prior registration.
242+
private func setupClearLogsListener() {
243+
let center = CFNotificationCenterGetDarwinNotifyCenter()
244+
let observer = Unmanaged.passUnretained(self).toOpaque()
245+
let name = "\(bundleIdentifier).\(ClearLogsParams.notificationName)" as CFString
246+
247+
CFNotificationCenterRemoveObserver(center, observer, CFNotificationName(name), nil)
248+
249+
CFNotificationCenterAddObserver(
250+
center,
251+
observer,
252+
{ _, observer, _, _, _ in
253+
guard let observer else { return }
254+
let provider = Unmanaged<AGPacketTunnelProvider>.fromOpaque(observer).takeUnretainedValue()
255+
provider.handleClearLogsNotification()
256+
},
257+
name,
258+
nil,
259+
.deliverImmediately
260+
)
261+
}
262+
263+
/// No-op if not registered.
264+
private func stopClearLogsListener() {
265+
let name = "\(bundleIdentifier).\(ClearLogsParams.notificationName)" as CFString
266+
CFNotificationCenterRemoveObserver(
267+
CFNotificationCenterGetDarwinNotifyCenter(),
268+
Unmanaged.passUnretained(self).toOpaque(),
269+
CFNotificationName(name),
270+
nil
271+
)
272+
}
273+
274+
private func handleClearLogsNotification() {
275+
fileLogger?.clearLogs()
276+
}
277+
278+
deinit {
279+
stopClearLogsListener()
280+
}
232281
}

platform/apple/TrustTunnelClient/Utils.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ internal struct ConnectionInfoParams {
66
static let notificationName = "connection_info"
77
}
88

9+
internal struct ClearLogsParams {
10+
static let notificationName = "clear_logs"
11+
}
12+
913
func configureIPv4AndIPv6Settings(from config: TunConfig) -> (NEIPv4Settings, NEIPv6Settings) {
1014
let ipv4Settings = NEIPv4Settings(addresses: ["10.0.0.2"], subnetMasks: ["255.255.255.0"])
1115
let ipv6Settings = NEIPv6Settings(addresses: ["fd00::1"], networkPrefixLengths: [64])

platform/apple/TrustTunnelClient/VpnManager.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,39 @@ public final class VpnManager {
501501
return results.map { $0.path }
502502
}
503503

504+
/// Remove all log files from both the app and Network Extension processes.
505+
/// Safe to call while the tunnel is running: the app clears `app.log`
506+
/// through its live writer, and posts a Darwin notification so a running
507+
/// NE clears its own open `extension.log` through its live writer. When the
508+
/// NE is not running, its file is deleted directly.
509+
public func clearLogs() -> Bool {
510+
guard let logsDir = FileLogger.logsDirectory(appGroup: appGroup) else {
511+
return false
512+
}
513+
514+
// Own log — cleared through the live writer.
515+
fileLogger?.clearLogs()
516+
517+
let manager = self.queue.sync { self.vpnManager }
518+
let neRunning = manager != nil
519+
&& manager!.connection.status != .disconnected
520+
&& manager!.connection.status != .invalid
521+
522+
if neRunning {
523+
// NE holds the file open — ask it to clear its own log.
524+
let name = "\(bundleIdentifier).\(ClearLogsParams.notificationName)" as CFString
525+
CFNotificationCenterPostNotification(
526+
CFNotificationCenterGetDarwinNotifyCenter(),
527+
CFNotificationName(name),
528+
nil, nil, true
529+
)
530+
} else {
531+
// NE not running — delete the files directly.
532+
FileLogger.clearLogs(directory: logsDir, baseName: FileLogger.extensionBaseName)
533+
}
534+
return true
535+
}
536+
504537
// MARK: - exportLogs helpers
505538

506539
private static let platformName: String = {

platform/testapp/android/app/src/main/kotlin/com/adguard/testapp/NativeCommunication.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ interface NativeVpnInterface {
6969
* directory. The caller is responsible for cleaning up these files.
7070
*/
7171
fun exportLogs(): List<String>
72+
/**
73+
* Clear all log files from the VPN process(es).
74+
*
75+
* On Apple, the VPN must be stopped before calling this.
76+
*/
77+
fun clearLogs()
7278

7379
companion object {
7480
/** The codec used by NativeVpnInterface. */
@@ -128,6 +134,22 @@ interface NativeVpnInterface {
128134
channel.setMessageHandler(null)
129135
}
130136
}
137+
run {
138+
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.com_adguard_testapp.NativeVpnInterface.clearLogs$separatedMessageChannelSuffix", codec)
139+
if (api != null) {
140+
channel.setMessageHandler { _, reply ->
141+
val wrapped: List<Any?> = try {
142+
api.clearLogs()
143+
listOf(null)
144+
} catch (exception: Throwable) {
145+
NativeCommunicationPigeonUtils.wrapError(exception)
146+
}
147+
reply.reply(wrapped)
148+
}
149+
} else {
150+
channel.setMessageHandler(null)
151+
}
152+
}
131153
}
132154
}
133155
}

platform/testapp/android/app/src/main/kotlin/com/adguard/testapp/NativeVpnImpl.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,8 @@ class NativeVpnImpl (
2828
override fun exportLogs(): List<String> {
2929
return VpnService.exportLogs(context)
3030
}
31+
32+
override fun clearLogs() {
33+
VpnService.clearLogs()
34+
}
3135
}

0 commit comments

Comments
 (0)