Просмотр исходного кода

Merge pull request #534 from mirokuratczyk/data_root_directory

Root data directory
Miro 6 лет назад
Родитель
Сommit
6a174d68de
31 измененных файлов с 1532 добавлено и 285 удалено
  1. 12 10
      ClientLibrary/clientlib/clientlib.go
  2. 21 15
      ConsoleClient/main.go
  3. 3 1
      MobileLibrary/Android/PsiphonTunnel/AndroidManifest.xml
  4. 35 10
      MobileLibrary/Android/PsiphonTunnel/PsiphonTunnel.java
  5. 4 0
      MobileLibrary/Android/PsiphonTunnel/ca_psiphon_psiphontunnel_backup_rules.xml
  6. 2 0
      MobileLibrary/Android/make.bash
  7. 18 1
      MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel.xcodeproj/project.pbxproj
  8. 8 0
      MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
  9. 9 13
      MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel.xcodeproj/xcshareddata/xcschemes/PsiphonTunnel.xcscheme
  10. 34 0
      MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Files/Backups.h
  11. 38 0
      MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Files/Backups.m
  12. 35 13
      MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.h
  13. 148 82
      MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.m
  14. 43 13
      MobileLibrary/psi/psi.go
  15. 56 0
      psiphon/common/utils.go
  16. 607 80
      psiphon/config.go
  17. 417 1
      psiphon/config_test.go
  18. 1 4
      psiphon/controller_test.go
  19. 1 1
      psiphon/dataStore.go
  20. 2 2
      psiphon/dataStoreRecovery_test.go
  21. 1 1
      psiphon/dialParameters_test.go
  22. 1 1
      psiphon/exchange_test.go
  23. 1 1
      psiphon/limitProtocols_test.go
  24. 1 3
      psiphon/memory_test/memory_test.go
  25. 3 3
      psiphon/notice.go
  26. 7 7
      psiphon/remoteServerList.go
  27. 11 9
      psiphon/remoteServerList_test.go
  28. 1 1
      psiphon/server/sessionID_test.go
  29. 1 1
      psiphon/tunnel.go
  30. 10 11
      psiphon/upgradeDownload.go
  31. 1 1
      psiphon/userAgent_test.go

+ 12 - 10
ClientLibrary/clientlib/clientlib.go

@@ -127,10 +127,12 @@ func StartTunnel(ctx context.Context,
 
 	// Use params.DataRootDirectory to set related config values.
 	if params.DataRootDirectory != nil {
-		config.DataStoreDirectory = *params.DataRootDirectory
-		config.ObfuscatedServerListDownloadDirectory = *params.DataRootDirectory
+		config.DataRootDirectory = *params.DataRootDirectory
 
-		config.RemoteServerListDownloadFilename = filepath.Join(*params.DataRootDirectory, "server_list_compressed")
+		// Migrate old fields
+		config.MigrateDataStoreDirectory = *params.DataRootDirectory
+		config.MigrateObfuscatedServerListDownloadDirectory = *params.DataRootDirectory
+		config.MigrateRemoteServerListDownloadFilename = filepath.Join(*params.DataRootDirectory, "server_list_compressed")
 	}
 
 	if params.NetworkID != nil {
@@ -145,6 +147,13 @@ func StartTunnel(ctx context.Context,
 		config.EstablishTunnelTimeoutSeconds = params.EstablishTunnelTimeoutSeconds
 	} // else use the value in config
 
+	if config.UseNoticeFiles == nil && config.EmitDiagnosticNotices && params.EmitDiagnosticNoticesToFiles {
+		config.UseNoticeFiles = &psiphon.UseNoticeFiles{
+			RotatingFileSize:      0,
+			RotatingSyncFrequency: 0,
+		}
+	} // else use the value in the config
+
 	// config.Commit must be called before calling config.SetClientParameters
 	// or attempting to connect.
 	err = config.Commit()
@@ -161,13 +170,6 @@ func StartTunnel(ctx context.Context,
 		}
 	}
 
-	if config.EmitDiagnosticNotices && params.EmitDiagnosticNoticesToFiles {
-		err := psiphon.SetNoticeFiles("", filepath.Join(config.DataStoreDirectory, "diagnostics.log"), 0, 0)
-		if err != nil {
-			return nil, errors.TraceMsg(err, "failed to initialize diagnostic logging")
-		}
-	}
-
 	err = psiphon.OpenDataStore(config)
 	if err != nil {
 		return nil, errors.TraceMsg(err, "failed to open data store")

+ 21 - 15
ConsoleClient/main.go

@@ -48,6 +48,9 @@ func main() {
 	var configFilename string
 	flag.StringVar(&configFilename, "config", "", "configuration input file")
 
+	var dataRootDirectory string
+	flag.StringVar(&dataRootDirectory, "dataRootDirectory", "", "directory where persistent files will be stored")
+
 	var embeddedServerEntryListFilename string
 	flag.StringVar(&embeddedServerEntryListFilename, "serverList", "", "embedded server entry list input file")
 
@@ -87,11 +90,9 @@ func main() {
 	var noticeFilename string
 	flag.StringVar(&noticeFilename, "notices", "", "notices output file (defaults to stderr)")
 
-	var homepageFilename string
-	flag.StringVar(&homepageFilename, "homepages", "", "homepages notices output file")
-
-	var rotatingFilename string
-	flag.StringVar(&rotatingFilename, "rotating", "", "rotating notices output file")
+	var useNoticeFiles bool
+	useNoticeFilesUsage := fmt.Sprintf("output homepage notices and rotating notices to <dataRootDirectory>/%s and <dataRootDirectory>/%s respectively", psiphon.HomepageFilename, psiphon.NoticesFilename)
+	flag.BoolVar(&useNoticeFiles, "useNoticeFiles", false, useNoticeFilesUsage)
 
 	var rotatingFileSize int
 	flag.IntVar(&rotatingFileSize, "rotatingFileSize", 1<<20, "rotating notices file size")
@@ -151,15 +152,6 @@ func main() {
 		noticeWriter = psiphon.NewNoticeConsoleRewriter(noticeWriter)
 	}
 	psiphon.SetNoticeWriter(noticeWriter)
-	err := psiphon.SetNoticeFiles(
-		homepageFilename,
-		rotatingFilename,
-		rotatingFileSize,
-		rotatingSyncFrequency)
-	if err != nil {
-		fmt.Printf("error initializing notice files: %s\n", err)
-		os.Exit(1)
-	}
 
 	// Handle required config file parameter
 
@@ -184,10 +176,24 @@ func main() {
 		os.Exit(1)
 	}
 
+	// Set data root directory
+	if dataRootDirectory != "" {
+		config.DataRootDirectory = dataRootDirectory
+	}
+
 	if interfaceName != "" {
 		config.ListenInterface = interfaceName
 	}
 
+	// Configure notice files
+
+	if useNoticeFiles {
+		config.UseNoticeFiles = &psiphon.UseNoticeFiles{
+			RotatingFileSize:      rotatingFileSize,
+			RotatingSyncFrequency: rotatingSyncFrequency,
+		}
+	}
+
 	// Configure packet tunnel, including updating the config.
 
 	if tun.IsSupported() && tunDevice != "" {
@@ -305,7 +311,7 @@ func main() {
 			profileSampleDurationSeconds := 5
 			common.WriteRuntimeProfiles(
 				psiphon.NoticeCommonLogger(),
-				config.DataStoreDirectory,
+				config.DataRootDirectory,
 				"",
 				profileSampleDurationSeconds,
 				profileSampleDurationSeconds)

+ 3 - 1
MobileLibrary/Android/PsiphonTunnel/AndroidManifest.xml

@@ -1,2 +1,4 @@
 <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="ca.psiphon">
-<uses-sdk android:minSdkVersion="15"/></manifest>
+    <uses-sdk android:minSdkVersion="15"/>
+    <application android:fullBackupContent="@xml/ca_psiphon_psiphontunnel_backup_rules"/>
+</manifest>

+ 35 - 10
MobileLibrary/Android/PsiphonTunnel/PsiphonTunnel.java

@@ -135,6 +135,23 @@ public class PsiphonTunnel {
         return mPsiphonTunnel;
     }
 
+    // Returns default path where upgrade downloads will be paved. Only applicable if
+    // DataRootDirectory was not set in the outer config. If DataRootDirectory was set in the
+    // outer config, use getUpgradeDownloadFilePath with its value instead.
+    public static String getDefaultUpgradeDownloadFilePath(Context context) {
+        return Psi.upgradeDownloadFilePath(defaultDataRootDirectory(context).getAbsolutePath());
+    }
+
+    // Returns the path where upgrade downloads will be paved relative to the configured
+    // DataRootDirectory.
+    public static String getUpgradeDownloadFilePath(String dataRootDirectoryPath) {
+        return Psi.upgradeDownloadFilePath(dataRootDirectoryPath);
+    }
+
+    private static File defaultDataRootDirectory(Context context) {
+        return context.getFileStreamPath("ca.psiphon.PsiphonTunnel.tunnel-core");
+    }
+
     private PsiphonTunnel(HostService hostService, boolean shouldRouteThroughTunnelAutomatically) {
         mHostService = hostService;
         mVpnMode = new AtomicBoolean(false);
@@ -594,7 +611,7 @@ public class PsiphonTunnel {
     }
 
     private String loadPsiphonConfig(Context context)
-            throws IOException, JSONException {
+            throws IOException, JSONException, Exception {
 
         // Load settings from the raw resource JSON config file and
         // update as necessary. Then write JSON to disk for the Go client.
@@ -603,23 +620,31 @@ public class PsiphonTunnel {
         // On Android, this directory must be set to the app private storage area.
         // The Psiphon library won't be able to use its current working directory
         // and the standard temporary directories do not exist.
+        if (!json.has("DataRootDirectory")) {
+            File dataRootDirectory = defaultDataRootDirectory(context);
+            if (!dataRootDirectory.exists()) {
+                boolean created = dataRootDirectory.mkdir();
+                if (!created) {
+                    throw new Exception("failed to create data root directory: " + dataRootDirectory.getPath());
+                }
+            }
+            json.put("DataRootDirectory", defaultDataRootDirectory(context));
+        }
+
+        // Migrate datastore files from legacy directory.
         if (!json.has("DataStoreDirectory")) {
-            json.put("DataStoreDirectory", context.getFilesDir());
+            json.put("MigrateDataStoreDirectory", context.getFilesDir());
         }
 
+        // Migrate remote server list downloads from legacy location.
         if (!json.has("RemoteServerListDownloadFilename")) {
             File remoteServerListDownload = new File(context.getFilesDir(), "remote_server_list");
-            json.put("RemoteServerListDownloadFilename", remoteServerListDownload.getAbsolutePath());
+            json.put("MigrateRemoteServerListDownloadFilename", remoteServerListDownload.getAbsolutePath());
         }
 
+        // Migrate obfuscated server list download files from legacy directory.
         File oslDownloadDir = new File(context.getFilesDir(), "osl");
-        if (!oslDownloadDir.exists()
-                && !oslDownloadDir.mkdirs()) {
-            // Failed to create osl directory
-            // TODO: proceed anyway?
-            throw new IOException("failed to create OSL download directory");
-        }
-        json.put("ObfuscatedServerListDownloadDirectory", oslDownloadDir.getAbsolutePath());
+        json.put("MigrateObfuscatedServerListDownloadDirectory", oslDownloadDir.getAbsolutePath());
 
         // Note: onConnecting/onConnected logic assumes 1 tunnel connection
         json.put("TunnelPoolSize", 1);

+ 4 - 0
MobileLibrary/Android/PsiphonTunnel/ca_psiphon_psiphontunnel_backup_rules.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<full-backup-content>
+    <include domain="file" path="ca.psiphon.PsiphonTunnel.tunnel-core" />
+</full-backup-content>

+ 2 - 0
MobileLibrary/Android/make.bash

@@ -75,6 +75,8 @@ yes | cp -f PsiphonTunnel/libs/armeabi-v7a/libtun2socks.so build-tmp/psi/jni/arm
 yes | cp -f PsiphonTunnel/libs/arm64-v8a/libtun2socks.so build-tmp/psi/jni/arm64-v8a/libtun2socks.so
 yes | cp -f PsiphonTunnel/libs/x86/libtun2socks.so build-tmp/psi/jni/x86/libtun2socks.so
 yes | cp -f PsiphonTunnel/libs/x86_64/libtun2socks.so build-tmp/psi/jni/x86_64/libtun2socks.so
+mkdir -p build-tmp/psi/res/xml
+yes | cp -f PsiphonTunnel/ca_psiphon_psiphontunnel_backup_rules.xml build-tmp/psi/res/xml/ca_psiphon_psiphontunnel_backup_rules.xml
 
 javac -d build-tmp -bootclasspath $ANDROID_HOME/platforms/android-23/android.jar -source 1.8 -target 1.8 -classpath build-tmp/psi/classes.jar PsiphonTunnel/PsiphonTunnel.java
 if [ $? != 0 ]; then

+ 18 - 1
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel.xcodeproj/project.pbxproj

@@ -40,6 +40,8 @@
 		66BDB0661DC26CCC0079384C /* SBJson4StreamWriterState.m in Sources */ = {isa = PBXBuildFile; fileRef = 66BDB0571DC26CCC0079384C /* SBJson4StreamWriterState.m */; };
 		66BDB0671DC26CCC0079384C /* SBJson4Writer.h in Headers */ = {isa = PBXBuildFile; fileRef = 66BDB0581DC26CCC0079384C /* SBJson4Writer.h */; };
 		66BDB0681DC26CCC0079384C /* SBJson4Writer.m in Sources */ = {isa = PBXBuildFile; fileRef = 66BDB0591DC26CCC0079384C /* SBJson4Writer.m */; };
+		CE3D1DA523906003009A4AF6 /* Backups.h in Headers */ = {isa = PBXBuildFile; fileRef = CE3D1DA323906003009A4AF6 /* Backups.h */; };
+		CE3D1DA623906003009A4AF6 /* Backups.m in Sources */ = {isa = PBXBuildFile; fileRef = CE3D1DA423906003009A4AF6 /* Backups.m */; };
 		EFED7EBF1F587F6E0078980F /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = EFED7EBE1F587F6E0078980F /* libresolv.tbd */; };
 /* End PBXBuildFile section */
 
@@ -102,6 +104,8 @@
 		66BDB0571DC26CCC0079384C /* SBJson4StreamWriterState.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SBJson4StreamWriterState.m; sourceTree = "<group>"; };
 		66BDB0581DC26CCC0079384C /* SBJson4Writer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SBJson4Writer.h; sourceTree = "<group>"; };
 		66BDB0591DC26CCC0079384C /* SBJson4Writer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SBJson4Writer.m; sourceTree = "<group>"; };
+		CE3D1DA323906003009A4AF6 /* Backups.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Backups.h; sourceTree = "<group>"; };
+		CE3D1DA423906003009A4AF6 /* Backups.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Backups.m; sourceTree = "<group>"; };
 		EFED7EBE1F587F6E0078980F /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; };
 /* End PBXFileReference section */
 
@@ -183,9 +187,10 @@
 		66BDB0221DA6BFCC0079384C /* PsiphonTunnel */ = {
 			isa = PBXGroup;
 			children = (
+				CE3D1D9E239056E4009A4AF6 /* Files */,
 				66BAD3321E525FBC00CD06DE /* JailbreakCheck */,
-				662659241DD270E900872F6C /* Reachability */,
 				66BDB04A1DC26CCC0079384C /* json-framework */,
+				662659241DD270E900872F6C /* Reachability */,
 				66BDB0231DA6BFCC0079384C /* PsiphonTunnel.h */,
 				66BDB0431DA6C7DD0079384C /* PsiphonTunnel.m */,
 				66BDB0241DA6BFCC0079384C /* Info.plist */,
@@ -233,6 +238,15 @@
 			path = "json-framework";
 			sourceTree = "<group>";
 		};
+		CE3D1D9E239056E4009A4AF6 /* Files */ = {
+			isa = PBXGroup;
+			children = (
+				CE3D1DA323906003009A4AF6 /* Backups.h */,
+				CE3D1DA423906003009A4AF6 /* Backups.m */,
+			);
+			path = Files;
+			sourceTree = "<group>";
+		};
 		EFED7EBD1F587F6E0078980F /* Frameworks */ = {
 			isa = PBXGroup;
 			children = (
@@ -248,6 +262,7 @@
 			isa = PBXHeadersBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				CE3D1DA523906003009A4AF6 /* Backups.h in Headers */,
 				6685BDCB1E2E882800F0E414 /* ref.h in Headers */,
 				66BAD3351E525FBC00CD06DE /* JailbreakCheck.h in Headers */,
 				4E89F7FF1E2ED3CE00005F4C /* LookupIPv6.h in Headers */,
@@ -335,6 +350,7 @@
 			developmentRegion = English;
 			hasScannedForEncodings = 0;
 			knownRegions = (
+				English,
 				en,
 			);
 			mainGroup = 66BDB0161DA6BFCC0079384C;
@@ -399,6 +415,7 @@
 				66BDB0441DA6C7DD0079384C /* PsiphonTunnel.m in Sources */,
 				662659281DD270E900872F6C /* Reachability.m in Sources */,
 				66BDB0601DC26CCC0079384C /* SBJson4StreamParserState.m in Sources */,
+				CE3D1DA623906003009A4AF6 /* Backups.m in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};

+ 8 - 0
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>IDEDidComputeMac32BitWarning</key>
+	<true/>
+</dict>
+</plist>

+ 9 - 13
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel.xcodeproj/xcshareddata/xcschemes/PsiphonTunnel.xcscheme

@@ -27,6 +27,15 @@
       selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
       selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
       shouldUseLaunchSchemeArgsEnv = "YES">
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "66BDB01F1DA6BFCC0079384C"
+            BuildableName = "PsiphonTunnel.framework"
+            BlueprintName = "PsiphonTunnel"
+            ReferencedContainer = "container:PsiphonTunnel.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
       <Testables>
          <TestableReference
             skipped = "NO">
@@ -39,17 +48,6 @@
             </BuildableReference>
          </TestableReference>
       </Testables>
-      <MacroExpansion>
-         <BuildableReference
-            BuildableIdentifier = "primary"
-            BlueprintIdentifier = "66BDB01F1DA6BFCC0079384C"
-            BuildableName = "PsiphonTunnel.framework"
-            BlueprintName = "PsiphonTunnel"
-            ReferencedContainer = "container:PsiphonTunnel.xcodeproj">
-         </BuildableReference>
-      </MacroExpansion>
-      <AdditionalOptions>
-      </AdditionalOptions>
    </TestAction>
    <LaunchAction
       buildConfiguration = "Debug"
@@ -70,8 +68,6 @@
             ReferencedContainer = "container:PsiphonTunnel.xcodeproj">
          </BuildableReference>
       </MacroExpansion>
-      <AdditionalOptions>
-      </AdditionalOptions>
    </LaunchAction>
    <ProfileAction
       buildConfiguration = "Release"

+ 34 - 0
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Files/Backups.h

@@ -0,0 +1,34 @@
+/*
+* Copyright (c) 2019, Psiphon Inc.
+* All rights reserved.
+*
+* This program is free software: you can redistribute it and/or modify
+* it under the terms of the GNU General Public License as published by
+* the Free Software Foundation, either version 3 of the License, or
+* (at your option) any later version.
+*
+* This program is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+* GNU General Public License for more details.
+*
+* You should have received a copy of the GNU General Public License
+* along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*
+*/
+
+#import <Foundation/Foundation.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface Backups : NSObject
+
+/// Excludes the target file from application backups made by iCloud and iTunes.
+/// If NO is returned, the file was not successfully excluded from backup and the error is populated.
+/// @param filePath Path at which the file exists.
+/// @param err If non-nil, contains the error encountered when attempting to exclude the file from backup.
++ (BOOL)excludeFileFromBackup:(NSString*)filePath err:(NSError**)err;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 38 - 0
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Files/Backups.m

@@ -0,0 +1,38 @@
+/*
+* Copyright (c) 2019, Psiphon Inc.
+* All rights reserved.
+*
+* This program is free software: you can redistribute it and/or modify
+* it under the terms of the GNU General Public License as published by
+* the Free Software Foundation, either version 3 of the License, or
+* (at your option) any later version.
+*
+* This program is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+* GNU General Public License for more details.
+*
+* You should have received a copy of the GNU General Public License
+* along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*
+*/
+
+#import "Backups.h"
+
+@implementation Backups
+
+// See comment in header
++ (BOOL)excludeFileFromBackup:(NSString*)filePath err:(NSError**)err {
+    *err = nil;
+
+    // The URL must be of the file scheme ("file://"), otherwise the `setResourceValue:forKey:error`
+    // operation will silently fail with: "CFURLCopyResourcePropertyForKey failed because passed URL
+    // no scheme".
+    NSURL *urlWithScheme = [NSURL fileURLWithPath:filePath];
+
+    return [urlWithScheme setResourceValue:[NSNumber numberWithBool:YES]
+                                    forKey:NSURLIsExcludedFromBackupKey
+                                     error:err];
+}
+
+@end

+ 35 - 13
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.h

@@ -139,18 +139,6 @@ typedef NS_ENUM(NSInteger, PsiphonConnectionState)
  */
 - (NSString * _Nullable)getEmbeddedServerEntriesPath;
 
-/*!
-  Called when the tunnel is starting. If this method is implemented, it should return the path where a homepage
-  notices file is to be written. This path should be writable by the library.
- */
-- (NSString * _Nullable)getHomepageNoticesPath;
-
-/*!
-  Called when the tunnel is starting. If this method is implemented, it should return the path where a rotating
-  notice file set is to be written. path file should be writable by the library.
- */
-- (NSString * _Nullable)getRotatingNoticesPath;
-
 /*!
  Gets runtime errors info that may be useful for debugging.
  @param message  The diagnostic message string.
@@ -323,6 +311,41 @@ Swift: @code func onInternetReachabilityChanged(_ currentReachability: Reachabil
  */
 + (PsiphonTunnel * _Nonnull)newPsiphonTunnel:(id<TunneledAppDelegate> _Nonnull)tunneledAppDelegate;
 
+/*!
+Returns the default data root directory that is used by PsiphonTunnel if DataRootDirectory is not specified in the config returned by
+getPsiphonConfig.
+@param err Any error encountered while obtaining the default data root directory. If set, the return value should be ignored.
+@return  The default data root directory used by PsiphonTunnel.
+*/
++ (NSURL * _Nullable)defaultDataRootDirectoryWithError:(NSError * _Nullable * _Nonnull)err;
+
+/*!
+Returns the path where the homepage notices file will be created.
+@note    This file will only be created if UseNoticeFiles is set in the config returned by `getPsiphonConfig`.
+@param dataRootDirectory the configured data root directory. If DataRootDirectory is not specified in the config returned by
+getPsiphonConfig, then use `defaultDataRootDirectory`.
+@return  The file path at which the homepage file will be created.
+*/
++ (NSURL * _Nullable)homepageFilePath:(NSURL * _Nonnull)dataRootDirectory;
+
+/*!
+Returns the path where the notices file will be created. When the file is rotated it will be moved to `oldNoticesFilePath`.
+@note    This file will only be created if UseNoticeFiles is set in the config returned by `getPsiphonConfig`.
+@param dataRootDirectory the configured data root directory. If DataRootDirectory is not specified in the config returned by
+`getPsiphonConfig`, then use `defaultDataRootDirectory`.
+@return  The file path at which the notices file will be created.
+*/
++ (NSURL * _Nullable)noticesFilePath:(NSURL * _Nonnull)dataRootDirectory;
+
+/*!
+Returns the path where the rotated notices file will be created.
+@note    This file will only be created if UseNoticeFiles is set in the config returned by `getPsiphonConfig`.
+@param dataRootDirectory the configured data root directory. If DataRootDirectory is not specified in the config returned by
+`getPsiphonConfig`, then use `defaultDataRootDirectory`.
+@return  The file path at which the rotated notices file can be found once rotated.
+*/
++ (NSURL * _Nullable)olderNoticesFilePath:(NSURL * _Nonnull)dataRootDirectory;
+
 /*!
  Start connecting the PsiphonTunnel. Returns before connection is complete -- delegate callbacks (such as `onConnected` and `onConnectionStateChanged`) are used to indicate progress and state.
  @param ifNeeded  If TRUE, the tunnel will only be started if it's not already connected and healthy. If FALSE, the tunnel will be forced to stop and reconnect.
@@ -331,7 +354,6 @@ Swift: @code func onInternetReachabilityChanged(_ currentReachability: Reachabil
  */
 - (BOOL)start:(BOOL)ifNeeded;
 
-
 /*!
  Reconnect a previously started PsiphonTunnel with the specified config changes.
  reconnectWithConfig has no effect if there is no running PsiphonTunnel.

+ 148 - 82
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.m

@@ -26,6 +26,7 @@
 #import "LookupIPv6.h"
 #import "Psi-meta.h"
 #import "PsiphonTunnel.h"
+#import "Backups.h"
 #import "json-framework/SBJson4.h"
 #import "JailbreakCheck/JailbreakCheck.h"
 #import <ifaddrs.h>
@@ -35,6 +36,26 @@
 #define GOOGLE_DNS_1 @"8.8.4.4"
 #define GOOGLE_DNS_2 @"8.8.8.8"
 
+NSErrorDomain _Nonnull const PsiphonTunnelErrorDomain = @"com.psiphon3.ios.PsiphonTunnelErrorDomain";
+
+/// Error codes which can returned by PsiphonTunnel
+typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
+
+    /*!
+     * Unknown error.
+     */
+    PsiphonTunnelErrorCodeUnknown = -1,
+
+    /*!
+     * An error was encountered either obtaining the default library directory.
+     * @code
+     * // Underlying error will be set with more information
+     * [error.userInfo objectForKey:NSUnderlyingErrorKey]
+     * @endcode
+     */
+    PsiphonTunnelErrorCodeLibraryDirectoryError,
+};
+
 @interface PsiphonTunnel () <GoPsiPsiphonProvider>
 
 @property (weak) id <TunneledAppDelegate> tunneledAppDelegate;
@@ -70,7 +91,6 @@
     // Log timestamp formatter
     // Note: NSDateFormatter is threadsafe.
     NSDateFormatter *rfc3339Formatter;
-    
 }
 
 - (id)init {
@@ -116,6 +136,33 @@
 
 #pragma mark - PsiphonTunnel public methods
 
+// See comment in header
++ (NSURL*)defaultDataRootDirectoryWithError:(NSError**)err {
+    *err = nil;
+
+    NSURL *libraryURL = [PsiphonTunnel libraryURLWithError:err];
+    if (*err != nil) {
+        return nil;
+    }
+    return [libraryURL URLByAppendingPathComponent:@"com.psiphon3.ios.PsiphonTunnel.tunnel-core"
+                                       isDirectory:YES];
+}
+
+// See comment in header
++ (NSURL*)homepageFilePath:(NSURL*)dataRootDirectory {
+    return [NSURL URLWithString:GoPsiHomepageFilePath(dataRootDirectory.path)];
+}
+
+// See comment in header
++ (NSURL*)noticesFilePath:(NSURL*)dataRootDirectory {
+    return [NSURL URLWithString:GoPsiNoticesFilePath(dataRootDirectory.path)];
+}
+
+// See comment in header
++ (NSURL*)olderNoticesFilePath:(NSURL*)dataRootDirectory {
+    return [NSURL URLWithString:GoPsiOldNoticesFilePath(dataRootDirectory.path)];
+}
+
 // See comment in header
 + (PsiphonTunnel * _Nonnull)newPsiphonTunnel:(id<TunneledAppDelegate> _Nonnull)tunneledAppDelegate {
     @synchronized (PsiphonTunnel.self) {
@@ -195,13 +242,6 @@
 - (BOOL)start {
     @synchronized (PsiphonTunnel.self) {
 
-        // Initialize notice files for writing as early as possible, so all
-        // logMessages will be written, as NoticeUserLogs, to the rotating
-        // file when tunnel-core is managing diagnostics.
-        if ([self initNoticeFiles] == FALSE) {
-            return FALSE;
-        }
-
         [self stop];
 
         [self logMessage:@"Starting Psiphon library"];
@@ -277,48 +317,6 @@
     }
 }
 
-- (BOOL)initNoticeFiles {
-
-    __block NSString *homepageNoticesPath = @"";
-    if ([self.tunneledAppDelegate respondsToSelector:@selector(getHomepageNoticesPath)]) {
-        dispatch_sync(self->callbackQueue, ^{
-            homepageNoticesPath = [self.tunneledAppDelegate getHomepageNoticesPath];
-            if (homepageNoticesPath == nil) {
-                homepageNoticesPath = @"";
-            }
-        });
-    }
-
-    __block NSString *rotatingNoticesPath = @"";
-    if ([self.tunneledAppDelegate respondsToSelector:@selector(getRotatingNoticesPath)]) {
-        dispatch_sync(self->callbackQueue, ^{
-            rotatingNoticesPath = [self.tunneledAppDelegate getRotatingNoticesPath];
-            if (rotatingNoticesPath == nil) {
-                rotatingNoticesPath = @"";
-            }
-        });
-    }
-
-    if (rotatingNoticesPath.length > 0) {
-        atomic_store(&self->usingRotatingNotices, TRUE);
-    }
-
-    NSError *e = nil;
-    GoPsiSetNoticeFiles(
-        homepageNoticesPath,
-        rotatingNoticesPath,
-        0, // Use default rotating settings
-        0, // ...
-        &e);
-    if (e != nil) {
-        [self logMessage:[NSString stringWithFormat: @"Psiphon library initialize notices failed: %@", e.localizedDescription]];
-        [self changeConnectionStateTo:PsiphonConnectionStateDisconnected evenIfSameState:NO];
-        return FALSE;
-    }
-
-    return TRUE;
-}
-
 /*!
  Start the tunnel if it's not already started.
  */
@@ -449,6 +447,28 @@
 
 #pragma mark - PsiphonTunnel logic implementation methods (private)
 
++ (NSURL*)libraryURLWithError:(NSError**)err {
+
+    *err = nil;
+
+    NSFileManager *fileManager = [NSFileManager defaultManager];
+
+    NSError *urlForDirectoryError;
+    NSURL *libraryURL = [fileManager URLForDirectory:NSLibraryDirectory
+                                            inDomain:NSUserDomainMask
+                                   appropriateForURL:nil
+                                              create:NO
+                                               error:&urlForDirectoryError];
+
+    if (urlForDirectoryError != nil) {
+        *err = [NSError errorWithDomain:PsiphonTunnelErrorDomain
+                                   code:PsiphonTunnelErrorCodeLibraryDirectoryError
+                               userInfo:@{NSUnderlyingErrorKey:urlForDirectoryError}];
+    }
+
+    return libraryURL;
+}
+
 /*!
  Build the config string for the tunnel.
  @returns String containing the JSON config. `nil` on error.
@@ -526,24 +546,72 @@
         config[@"EstablishTunnelTimeoutSeconds"] = [NSNumber numberWithInt:0];
     }
 
-    
-    NSFileManager *fileManager = [NSFileManager defaultManager];
-    
-    NSError* err = nil;
-    NSURL *libraryURL = [fileManager URLForDirectory:NSLibraryDirectory inDomain:NSUserDomainMask appropriateForURL:nil create:YES error:&err];
-    
-    if (libraryURL == nil) {
-        [self logMessage:[NSString stringWithFormat: @"Unable to get Library URL: %@", err.localizedDescription]];
-        return nil;
+    //
+    // DataRootDirectory
+    //
+
+    NSError *err;
+
+    // Some clients will have a data directory that they'd prefer the Psiphon
+    // library use, but if not we'll default to the user Library directory.
+    //
+    // Note: this deprecates the "DataStoreDirectory" config field.
+    NSURL *defaultDataRootDirectoryURL = [PsiphonTunnel defaultDataRootDirectoryWithError:&err];
+    if (err != nil) {
+       [self logMessage:[NSString stringWithFormat:@"Unable to get defaultDataRootDirectoryURL: %@", err.localizedDescription]];
+       return nil;
     }
-    
+
+    if (config[@"DataRootDirectory"] == nil) {
+
+        NSFileManager *fileManager = [NSFileManager defaultManager];
+
+        [fileManager createDirectoryAtURL:defaultDataRootDirectoryURL
+              withIntermediateDirectories:YES
+                               attributes:nil
+                                    error:&err];
+        if (err != nil) {
+           [self logMessage:[NSString stringWithFormat: @"Unable to create defaultRootDirectoryURL '%@': %@", defaultDataRootDirectoryURL, err.localizedDescription]];
+           return nil;
+        }
+
+        config[@"DataRootDirectory"] = defaultDataRootDirectoryURL.path;
+    }
+    else {
+        [self logMessage:[NSString stringWithFormat:@"DataRootDirectory overridden from '%@' to '%@'", defaultDataRootDirectoryURL.path, config[@"DataRootDirectory"]]];
+    }
+
+    // Ensure that the configured data root directory is not backed up to iCloud or iTunes.
+    NSURL *dataRootDirectory = [NSURL fileURLWithPath:config[@"DataRootDirectory"]];
+
+    BOOL succeeded = [Backups excludeFileFromBackup:dataRootDirectory.path err:&err];
+    if (!succeeded) {
+        NSString *msg = [NSString stringWithFormat:@"Failed to exclude data root directory from backup: %@", err.localizedDescription];
+        [self logMessage:msg];
+    } else {
+        [self logMessage:@"Excluded data root directory from backup"];
+    }
+
     //
     // DataStoreDirectory
     //
-    
+
+    NSURL *libraryURL = [PsiphonTunnel libraryURLWithError:&err];
+    if (err != nil) {
+        [self logMessage:[NSString stringWithFormat: @"Unable to get Library URL: %@", err.localizedDescription]];
+        return nil;
+    }
+
     // Some clients will have a data directory that they'd prefer the Psiphon
     // library use, but if not we'll default to the user Library directory.
-    NSURL *defaultDataStoreDirectoryURL = [libraryURL URLByAppendingPathComponent:@"datastore" isDirectory:YES];
+    //
+    // Deprecated:
+    // Tunnel core now stores its files under a single data root directory, which can be configured.
+    // Setting the datastore directory allows tunnel core to migrate datastore files from the old
+    // directory structure to the new one; this can be done with either the deprecated config field
+    // "DataStoreDirectory" or the more explict new field "MigrateDataStoreDirectory".
+    NSURL *defaultDataStoreDirectoryURL = [libraryURL URLByAppendingPathComponent:@"datastore"
+                                                                      isDirectory:YES];
     
     if (defaultDataStoreDirectoryURL == nil) {
         [self logMessage:@"Unable to create defaultDataStoreDirectoryURL"];
@@ -551,22 +619,22 @@
     }
     
     if (config[@"DataStoreDirectory"] == nil) {
-        [fileManager createDirectoryAtURL:defaultDataStoreDirectoryURL withIntermediateDirectories:YES attributes:nil error:&err];
-        if (err != nil) {
-            [self logMessage:[NSString stringWithFormat: @"Unable to create defaultDataStoreDirectoryURL: %@", err.localizedDescription]];
-            return nil;
-        }
-        
-        config[@"DataStoreDirectory"] = [defaultDataStoreDirectoryURL path];
+        config[@"MigrateDataStoreDirectory"] = defaultDataStoreDirectoryURL.path;
     }
     else {
         [self logMessage:[NSString stringWithFormat: @"DataStoreDirectory overridden from '%@' to '%@'", [defaultDataStoreDirectoryURL path], config[@"DataStoreDirectory"]]];
     }
-    
+
     //
     // Remote Server List
     //
-    
+
+    // Deprecated:
+    // Tunnel core now stores its files under a single data root directory, which can be configured.
+    // Setting the remote server list download filename allows tunnel core to migrate remote server
+    // list download files to the new directory structure under the data root directory; this can be
+    // done with either the deprecated config field "RemoteServerListDownloadFilename" or the more
+    // explict new field "MigrateRemoteServerListDownloadFilename".
     NSString *defaultRemoteServerListFilename = [[libraryURL URLByAppendingPathComponent:@"remote_server_list" isDirectory:NO] path];
     if (defaultRemoteServerListFilename == nil) {
         [self logMessage:@"Unable to create defaultRemoteServerListFilename"];
@@ -574,7 +642,7 @@
     }
     
     if (config[@"RemoteServerListDownloadFilename"] == nil) {
-        config[@"RemoteServerListDownloadFilename"] = defaultRemoteServerListFilename;
+        config[@"MigrateRemoteServerListDownloadFilename"] = defaultRemoteServerListFilename;
     }
     else {
         [self logMessage:[NSString stringWithFormat: @"RemoteServerListDownloadFilename overridden from '%@' to '%@'", defaultRemoteServerListFilename, config[@"RemoteServerListDownloadFilename"]]];
@@ -590,21 +658,20 @@
     //
     // Obfuscated Server List
     //
-    
+
+    // Deprecated:
+    // Tunnel core now stores its files under a single data root directory, which can be configured.
+    // Setting the obfuscated server list download directory allows tunnel core to migrate
+    // obfuscated server list files from the old directory structure to the new one; this can be
+    // done with either the deprecated config field "ObfuscatedServerListDownloadDirectory" or the
+    // more explict new field "MigrateObfuscatedServerListDownloadDirectory".
     NSURL *defaultOSLDirectoryURL = [libraryURL URLByAppendingPathComponent:@"osl" isDirectory:YES];
     if (defaultOSLDirectoryURL == nil) {
         [self logMessage:@"Unable to create defaultOSLDirectory"];
         return nil;
     }
-    
     if (config[@"ObfuscatedServerListDownloadDirectory"] == nil) {
-        [fileManager createDirectoryAtURL:defaultOSLDirectoryURL withIntermediateDirectories:YES attributes:nil error:&err];
-        if (err != nil) {
-            [self logMessage:[NSString stringWithFormat: @"Unable to create defaultOSLDirectoryURL: %@", err.localizedDescription]];
-            return nil;
-        }
-        
-        config[@"ObfuscatedServerListDownloadDirectory"] = [defaultOSLDirectoryURL path];
+        config[@"MigrateObfuscatedServerListDownloadDirectory"] = defaultOSLDirectoryURL.path;
     }
     else {
         [self logMessage:[NSString stringWithFormat: @"ObfuscatedServerListDownloadDirectory overridden from '%@' to '%@'", [defaultOSLDirectoryURL path], config[@"ObfuscatedServerListDownloadDirectory"]]];
@@ -1190,7 +1257,6 @@
     });
 }
 
-
 #pragma mark - Helpers (private)
 
 /**

+ 43 - 13
MobileLibrary/psi/psi.go

@@ -29,6 +29,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"os"
+	"path/filepath"
 	"strings"
 	"sync"
 
@@ -49,23 +50,52 @@ type PsiphonProvider interface {
 	GetNetworkID() string
 }
 
-func SetNoticeFiles(
-	homepageFilename,
-	rotatingFilename string,
-	rotatingFileSize,
-	rotatingSyncFrequency int) error {
-
-	return psiphon.SetNoticeFiles(
-		homepageFilename,
-		rotatingFilename,
-		rotatingFileSize,
-		rotatingSyncFrequency)
-}
-
 func NoticeUserLog(message string) {
 	psiphon.NoticeUserLog(message)
 }
 
+// HomepageFilePath returns the path where homepage files will be paved.
+//
+// rootDataDirectoryPath is the configured data root directory.
+//
+// Note: homepage files will only be paved if UseNoticeFiles is set in the
+// config passed to Start().
+func HomepageFilePath(rootDataDirectoryPath string) string {
+	return filepath.Join(rootDataDirectoryPath, psiphon.PsiphonDataDirectoryName, psiphon.HomepageFilename)
+}
+
+// NoticesFilePath returns the path where the notices file will be paved.
+//
+// rootDataDirectoryPath is the configured data root directory.
+//
+// Note: notices will only be paved if UseNoticeFiles is set in the config
+// passed to Start().
+func NoticesFilePath(rootDataDirectoryPath string) string {
+	return filepath.Join(rootDataDirectoryPath, psiphon.PsiphonDataDirectoryName, psiphon.NoticesFilename)
+}
+
+// OldNoticesFilePath returns the path where the notices file is moved to when
+// file rotation occurs.
+//
+// rootDataDirectoryPath is the configured data root directory.
+//
+// Note: notices will only be paved if UseNoticeFiles is set in the config
+// passed to Start().
+func OldNoticesFilePath(rootDataDirectoryPath string) string {
+	return filepath.Join(rootDataDirectoryPath, psiphon.PsiphonDataDirectoryName, psiphon.OldNoticesFilename)
+}
+
+// UpgradeDownloadFilePath returns the path where the downloaded upgrade file
+// will be paved.
+//
+// rootDataDirectoryPath is the configured data root directory.
+//
+// Note: upgrades will only be paved if UpgradeDownloadURLs is set in the config
+// passed to Start() and there are upgrades available.
+func UpgradeDownloadFilePath(rootDataDirectoryPath string) string {
+	return filepath.Join(rootDataDirectoryPath, psiphon.PsiphonDataDirectoryName, psiphon.UpgradeDownloadFilename)
+}
+
 var controllerMutex sync.Mutex
 var controller *psiphon.Controller
 var controllerCtx context.Context

+ 56 - 0
psiphon/common/utils.go

@@ -27,6 +27,7 @@ import (
 	"io"
 	"io/ioutil"
 	"math"
+	"os"
 	"time"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
@@ -178,3 +179,58 @@ func CopyNBuffer(dst io.Writer, src io.Reader, n int64, buf []byte) (written int
 	}
 	return
 }
+
+// FileExists returns true if a file, or directory, exists at the given path.
+func FileExists(filePath string) bool {
+	if _, err := os.Stat(filePath); err != nil && os.IsNotExist(err) {
+		return false
+	}
+	return true
+}
+
+// FileMigration represents the action of moving a file, or directory, to a new
+// location.
+type FileMigration struct {
+
+	// OldPath is the current location of the file.
+	OldPath string
+
+	// NewPath is the location that the file should be moved to.
+	NewPath string
+
+	// IsDir should be set to true if the file is a directory.
+	IsDir bool
+}
+
+// DoFileMigration performs the specified file move operation. An error will be
+// returned and the operation will not performed if: a file is expected, but a
+// directory is found; a directory is expected, but a file is found; or a file,
+// or directory, already exists at the target path of the move operation.
+func DoFileMigration(migration FileMigration) error {
+	if !FileExists(migration.OldPath) {
+		return errors.Tracef("%s does not exist", migration.OldPath)
+	}
+	info, err := os.Stat(migration.OldPath)
+	if err != nil {
+		return errors.Tracef("error getting file info of %s: %s", migration.OldPath, err.Error())
+	}
+	if info.IsDir() != migration.IsDir {
+		if migration.IsDir {
+			return errors.Tracef("expected directory %s to be directory but found file", migration.OldPath)
+		}
+
+		return errors.Tracef("expected %s to be file but found directory",
+			migration.OldPath)
+	}
+
+	if FileExists(migration.NewPath) {
+		return errors.Tracef("%s already exists, will not overwrite", migration.NewPath)
+	}
+
+	err = os.Rename(migration.OldPath, migration.NewPath)
+	if err != nil {
+		return errors.Tracef("renaming %s as %s failed with error %s", migration.OldPath, migration.NewPath, err.Error())
+	}
+
+	return nil
+}

+ 607 - 80
psiphon/config.go

@@ -25,8 +25,11 @@ import (
 	"encoding/binary"
 	"encoding/json"
 	"fmt"
+	"io/ioutil"
 	"net/http"
 	"os"
+	"path/filepath"
+	"regexp"
 	"strconv"
 	"strings"
 	"sync"
@@ -40,25 +43,52 @@ import (
 
 const (
 	TUNNEL_POOL_SIZE = 1
+
+	// Psiphon data directory name, relative to config.DataRootDirectory.
+	// See config.GetPsiphonDataDirectory().
+	PsiphonDataDirectoryName = "ca.psiphon.PsiphonTunnel.tunnel-core"
+
+	// Filename constants, all relative to config.GetPsiphonDataDirectory().
+	HomepageFilename        = "homepage"
+	NoticesFilename         = "notices"
+	OldNoticesFilename      = "notices.1"
+	UpgradeDownloadFilename = "upgrade"
 )
 
 // Config is the Psiphon configuration specified by the application. This
 // configuration controls the behavior of the core tunnel functionality.
 //
 // To distinguish omitted timeout params from explicit 0 value timeout params,
-// corresponding fieldss are int pointers. nil means no value was supplied and
+// corresponding fields are int pointers. nil means no value was supplied and
 // to use the default; a non-nil pointer to 0 means no timeout.
 type Config struct {
 
-	// DataStoreDirectory is the directory in which to store the persistent
-	// database, which contains information such as server entries. By
-	// default, current working directory.
+	// DataRootDirectory is the directory in which to store persistent files,
+	// which contain information such as server entries. By default, current
+	// working directory.
 	//
-	// Warning: If the datastore file, DataStoreDirectory/DATA_STORE_FILENAME,
-	// exists but fails to open for any reason (checksum error, unexpected
-	// file format, etc.) it will be deleted in order to pave a new datastore
-	// and continue running.
-	DataStoreDirectory string
+	// Psiphon will assume full control of files under this directory. They may
+	// be deleted, moved or overwritten.
+	DataRootDirectory string
+
+	// UseNoticeFiles configures notice files for writing. If set, homepages
+	// will be written to a file created at config.GetHomePageFilename()
+	// and notices will be written to a file created at
+	// config.GetNoticesFilename().
+	//
+	// The homepage file may be read after the Tunnels notice with count of 1.
+	//
+	// The value of UseNoticeFiles sets the size and frequency at which the
+	// notices file, config.GetNoticesFilename(), will be rotated. See the
+	// comment for UseNoticeFiles for more details. One rotated older file,
+	// config.GetOldNoticesFilename(), is retained.
+	//
+	// The notice files may be may be read at any time; and should be opened
+	// read-only for reading. Diagnostic notices are omitted from the notice
+	// files.
+	//
+	// See comment for setNoticeFiles in notice.go for further details.
+	UseNoticeFiles *UseNoticeFiles
 
 	// PropagationChannelId is a string identifier which indicates how the
 	// Psiphon client was distributed. This parameter is required. This value
@@ -126,13 +156,6 @@ type Config struct {
 	// When set, must be >= 1.0.
 	NetworkLatencyMultiplier float64
 
-	// TunnelProtocol indicates which protocol to use. For the default, "",
-	// all protocols are used.
-	//
-	// Deprecated: Use LimitTunnelProtocols. When LimitTunnelProtocols is not
-	// nil, this parameter is ignored.
-	TunnelProtocol string
-
 	// LimitTunnelProtocols indicates which protocols to use. Valid values
 	// include:
 	// "SSH", "OSSH", "UNFRONTED-MEEK-OSSH", "UNFRONTED-MEEK-HTTPS-OSSH",
@@ -223,10 +246,6 @@ type Config struct {
 	// upstream proxy when specified by UpstreamProxyURL.
 	CustomHeaders http.Header
 
-	// Deprecated: Use CustomHeaders. When CustomHeaders is not nil, this
-	// parameter is ignored.
-	UpstreamProxyCustomHeaders http.Header
-
 	// NetworkConnectivityChecker is an interface that enables tunnel-core to
 	// call into the host application to check for network connectivity. See:
 	// NetworkConnectivityChecker doc.
@@ -296,15 +315,6 @@ type Config struct {
 	// speed test samples.
 	TargetApiProtocol string
 
-	// RemoteServerListUrl is a URL which specifies a location to fetch out-
-	// of-band server entries. This facility is used when a tunnel cannot be
-	// established to known servers. This value is supplied by and depends on
-	// the Psiphon Network, and is typically embedded in the client binary.
-	//
-	// Deprecated: Use RemoteServerListURLs. When RemoteServerListURLs is not
-	// nil, this parameter is ignored.
-	RemoteServerListUrl string
-
 	// RemoteServerListURLs is list of URLs which specify locations to fetch
 	// out-of-band server entries. This facility is used when a tunnel cannot
 	// be established to known servers. This value is supplied by and depends
@@ -313,12 +323,6 @@ type Config struct {
 	// DownloadURL must have OnlyAfterAttempts = 0.
 	RemoteServerListURLs parameters.DownloadURLs
 
-	// RemoteServerListDownloadFilename specifies a target filename for
-	// storing the remote server list download. Data is stored in co-located
-	// files (RemoteServerListDownloadFilename.part*) to allow for resumable
-	// downloading.
-	RemoteServerListDownloadFilename string
-
 	// RemoteServerListSignaturePublicKey specifies a public key that's used
 	// to authenticate the remote server list payload. This value is supplied
 	// by and depends on the Psiphon Network, and is typically embedded in the
@@ -334,15 +338,6 @@ type Config struct {
 	// default value is used. This value is typical overridden for testing.
 	FetchRemoteServerListRetryPeriodMilliseconds *int
 
-	// ObfuscatedServerListRootURL is a URL which specifies the root location
-	// from which to fetch obfuscated server list files. This value is
-	// supplied by and depends on the Psiphon Network, and is typically
-	// embedded in the client binary.
-	//
-	// Deprecated: Use ObfuscatedServerListRootURLs. When
-	// ObfuscatedServerListRootURLs is not nil, this parameter is ignored.
-	ObfuscatedServerListRootURL string
-
 	// ObfuscatedServerListRootURLs is a list of URLs which specify root
 	// locations from which to fetch obfuscated server list files. This value
 	// is supplied by and depends on the Psiphon Network, and is typically
@@ -351,12 +346,6 @@ type Config struct {
 	// OnlyAfterAttempts = 0.
 	ObfuscatedServerListRootURLs parameters.DownloadURLs
 
-	// ObfuscatedServerListDownloadDirectory specifies a target directory for
-	// storing the obfuscated remote server list downloads. Data is stored in
-	// co-located files (<OSL filename>.part*) to allow for resumable
-	// downloading.
-	ObfuscatedServerListDownloadDirectory string
-
 	// SplitTunnelRoutesURLFormat is a URL which specifies the location of a
 	// routes file to use for split tunnel mode. The URL must include a
 	// placeholder for the client region to be supplied. Split tunnel mode
@@ -378,16 +367,6 @@ type Config struct {
 	// server must support TCP requests.
 	SplitTunnelDNSServer string
 
-	// UpgradeDownloadUrl specifies a URL from which to download a host client
-	// upgrade file, when one is available. The core tunnel controller
-	// provides a resumable download facility which downloads this resource
-	// and emits a notice when complete. This value is supplied by and depends
-	// on the Psiphon Network, and is typically embedded in the client binary.
-	//
-	// Deprecated: Use UpgradeDownloadURLs. When UpgradeDownloadURLs is not
-	// nil, this parameter is ignored.
-	UpgradeDownloadUrl string
-
 	// UpgradeDownloadURLs is list of URLs which specify locations from which
 	// to download a host client upgrade file, when one is available. The core
 	// tunnel controller provides a resumable download facility which
@@ -406,12 +385,6 @@ type Config struct {
 	// is specified.
 	UpgradeDownloadClientVersionHeader string
 
-	// UpgradeDownloadFilename is the local target filename for an upgrade
-	// download. This parameter is required when UpgradeDownloadURLs (or
-	// UpgradeDownloadUrl) is specified. Data is stored in co-located files
-	// (UpgradeDownloadFilename.part*) to allow for resumable downloading.
-	UpgradeDownloadFilename string
-
 	// FetchUpgradeRetryPeriodMilliseconds specifies the delay before resuming
 	// a client upgrade download after a failure. If omitted, a default value
 	// is used. This value is typical overridden for testing.
@@ -568,6 +541,162 @@ type Config struct {
 	// ApplicationParameters is for testing purposes.
 	ApplicationParameters parameters.KeyValues
 
+	// MigrateHompageNoticesFilename migrates a homepage file from the path
+	// previously configured with setNoticeFiles to the new path for homepage
+	// files under the data root directory. The file specified by this config
+	// value will be moved to config.GetHomePageFilename().
+	//
+	// Note: see comment for config.Commit() for a description of how file
+	// migrations are performed.
+	//
+	// If not set, no migration operation will be performed.
+	MigrateHompageNoticesFilename string
+
+	// MigrateRotatingNoticesFilename migrates notice files from the path
+	// previously configured with setNoticeFiles to the new path for notice
+	// files under the data root directory.
+	//
+	// MigrateRotatingNoticesFilename will be moved to
+	// config.GetNoticesFilename().
+	//
+	// MigrateRotatingNoticesFilename.1 will be moved to
+	// config.GetOldNoticesFilename().
+	//
+	// Note: see comment for config.Commit() for a description of how file
+	// migrations are performed.
+	//
+	// If not set, no migration operation will be performed.
+	MigrateRotatingNoticesFilename string
+
+	// DataStoreDirectory is the directory in which to store the persistent
+	// database, which contains information such as server entries. By
+	// default, current working directory.
+	//
+	// Deprecated:
+	// Use MigrateDataStoreDirectory. When MigrateDataStoreDirectory
+	// is set, this parameter is ignored.
+	//
+	// DataStoreDirectory has been subsumed by the new data root directory,
+	// which is configured with DataRootDirectory. If set, datastore files
+	// found in the specified directory will be moved under the data root
+	// directory.
+	DataStoreDirectory string
+
+	// MigrateDataStoreDirectory indicates the location of the datastore
+	// directory, as previously configured with the deprecated
+	// DataStoreDirectory config field. Datastore files found in the specified
+	// directory will be moved under the data root directory.
+	//
+	// Note: see comment for config.Commit() for a description of how file
+	// migrations are performed.
+	MigrateDataStoreDirectory string
+
+	// RemoteServerListDownloadFilename specifies a target filename for
+	// storing the remote server list download. Data is stored in co-located
+	// files (RemoteServerListDownloadFilename.part*) to allow for resumable
+	// downloading.
+	//
+	// Deprecated:
+	// Use MigrateRemoteServerListDownloadFilename. When
+	// MigrateRemoteServerListDownloadFilename is set, this parameter is
+	// ignored.
+	//
+	// If set, remote server list download files found at the specified path
+	// will be moved under the data root directory.
+	RemoteServerListDownloadFilename string
+
+	// MigrateRemoteServerListDownloadFilename indicates the location of
+	// remote server list download files. The remote server list files found at
+	// the specified path will be moved under the data root directory.
+	//
+	// Note: see comment for config.Commit() for a description of how file
+	// migrations are performed.
+	MigrateRemoteServerListDownloadFilename string
+
+	// ObfuscatedServerListDownloadDirectory specifies a target directory for
+	// storing the obfuscated remote server list downloads. Data is stored in
+	// co-located files (<OSL filename>.part*) to allow for resumable
+	// downloading.
+	//
+	// Deprecated:
+	// Use MigrateObfuscatedServerListDownloadDirectory. When
+	// MigrateObfuscatedServerListDownloadDirectory is set, this parameter is
+	// ignored.
+	//
+	// If set, obfuscated server list download files found at the specified path
+	// will be moved under the data root directory.
+	ObfuscatedServerListDownloadDirectory string
+
+	// MigrateObfuscatedServerListDownloadDirectory indicates the location of
+	// the obfuscated server list downloads directory, as previously configured
+	// with ObfuscatedServerListDownloadDirectory. Obfuscated server list
+	// download files found in the specified directory will be moved under the
+	// data root directory.
+	//
+	// Note: see comment for config.Commit() for a description of how file
+	// migrations are performed.
+	MigrateObfuscatedServerListDownloadDirectory string
+
+	// UpgradeDownloadFilename is the local target filename for an upgrade
+	// download. This parameter is required when UpgradeDownloadURLs (or
+	// UpgradeDownloadUrl) is specified. Data is stored in co-located files
+	// (UpgradeDownloadFilename.part*) to allow for resumable downloading.
+	//
+	// Deprecated:
+	// Use MigrateUpgradeDownloadFilename. When MigrateUpgradeDownloadFilename
+	// is set, this parameter is ignored.
+	//
+	// If set, upgrade download files found at the specified path will be moved
+	// under the data root directory.
+	UpgradeDownloadFilename string
+
+	// MigrateUpgradeDownloadFilename indicates the location of downloaded
+	// application upgrade files. Downloaded upgrade files found at the
+	// specified path will be moved under the data root directory.
+	//
+	// Note: see comment for config.Commit() for a description of how file
+	// migrations are performed.
+	MigrateUpgradeDownloadFilename string
+
+	// TunnelProtocol indicates which protocol to use. For the default, "",
+	// all protocols are used.
+	//
+	// Deprecated: Use LimitTunnelProtocols. When LimitTunnelProtocols is not
+	// nil, this parameter is ignored.
+	TunnelProtocol string
+
+	// Deprecated: Use CustomHeaders. When CustomHeaders is not nil, this
+	// parameter is ignored.
+	UpstreamProxyCustomHeaders http.Header
+
+	// RemoteServerListUrl is a URL which specifies a location to fetch out-
+	// of-band server entries. This facility is used when a tunnel cannot be
+	// established to known servers. This value is supplied by and depends on
+	// the Psiphon Network, and is typically embedded in the client binary.
+	//
+	// Deprecated: Use RemoteServerListURLs. When RemoteServerListURLs is not
+	// nil, this parameter is ignored.
+	RemoteServerListUrl string
+
+	// ObfuscatedServerListRootURL is a URL which specifies the root location
+	// from which to fetch obfuscated server list files. This value is
+	// supplied by and depends on the Psiphon Network, and is typically
+	// embedded in the client binary.
+	//
+	// Deprecated: Use ObfuscatedServerListRootURLs. When
+	// ObfuscatedServerListRootURLs is not nil, this parameter is ignored.
+	ObfuscatedServerListRootURL string
+
+	// UpgradeDownloadUrl specifies a URL from which to download a host client
+	// upgrade file, when one is available. The core tunnel controller
+	// provides a resumable download facility which downloads this resource
+	// and emits a notice when complete. This value is supplied by and depends
+	// on the Psiphon Network, and is typically embedded in the client binary.
+	//
+	// Deprecated: Use UpgradeDownloadURLs. When UpgradeDownloadURLs is not
+	// nil, this parameter is ignored.
+	UpgradeDownloadUrl string
+
 	// clientParameters is the active ClientParameters with defaults, config
 	// values, and, optionally, tactics applied.
 	//
@@ -589,6 +718,18 @@ type Config struct {
 	loadTimestamp string
 }
 
+// Config field which specifies if notice files should be used and at which
+// frequency and size they should be rotated.
+//
+// If either RotatingFileSize or RotatingSyncFrequency are <= 0, default values
+// are used.
+//
+// See comment for setNoticeFiles in notice.go for further details.
+type UseNoticeFiles struct {
+	RotatingFileSize      int
+	RotatingSyncFrequency int
+}
+
 // LoadConfig parses a JSON format Psiphon config JSON string and returns a
 // Config struct populated with config values.
 //
@@ -620,6 +761,35 @@ func (config *Config) IsCommitted() bool {
 //
 // Config fields should not be set after calling Config, as any changes may
 // not be reflected in internal data structures.
+//
+// File migrations:
+// Config fields of the naming Migrate* (e.g. MigrateDataStoreDirectory) specify
+// a file migration operation which should be performed. These fields correspond
+// to deprecated fields, which previously could be used to specify where Psiphon
+// stored different sets of persistent files (e.g. MigrateDataStoreDirectory
+// corresponds to the deprecated field DataStoreDirectory).
+//
+// Psiphon now stores all persistent data under the configurable
+// DataRootDirectory (see Config.DataRootDirectory). The deprecated fields, and
+// corresponding Migrate* fields, are now used to specify the file or directory
+// path where, or under which, persistent files and directories created by
+// previous versions of Psiphon exist, so they can be moved under the
+// DataRootDirectory.
+//
+// For each migration operation:
+// - In the case of directories that could have defaulted to the current working
+//   directory, persistent files and directories created by Psiphon are
+//   precisely targeted to avoid moving files which were not created by Psiphon.
+// - If no file is found at the specified path, or an error is encountered while
+//   migrating the file, then an error is logged and execution continues
+//   normally.
+//
+// A sentinel file which signals that file migration has been completed, and
+// should not be attempted again, is created under DataRootDirectory after one
+// full pass through Commit(), regardless of whether file migration succeeds or
+// fails. It is better to not endlessly retry file migrations on each Commit()
+// because file system errors are expected to be rare and persistent files will
+// be re-populated over time.
 func (config *Config) Commit() error {
 
 	// Do SetEmitDiagnosticNotices first, to ensure config file errors are
@@ -629,6 +799,84 @@ func (config *Config) Commit() error {
 			true, config.EmitDiagnosticNetworkParameters)
 	}
 
+	// Migrate and set notice files before any operations that may emit an
+	// error. This is to ensure config file errors are written to file when
+	// notice files are configured with config.UseNoticeFiles.
+	//
+	// Note:
+	// Errors encountered while configuring the data directory cannot be written
+	// to notice files. This is because notices files are created within the
+	// data directory.
+
+	if config.DataRootDirectory == "" {
+		wd, err := os.Getwd()
+		if err != nil {
+			return errors.Trace(err)
+		}
+		config.DataRootDirectory = wd
+	}
+
+	// Create root directory
+	dataDirectoryPath := config.GetPsiphonDataDirectory()
+	if !common.FileExists(dataDirectoryPath) {
+		err := os.Mkdir(dataDirectoryPath, os.ModePerm)
+		if err != nil {
+			return errors.Tracef("failed to create datastore directory %s with error: %s", dataDirectoryPath, err.Error())
+		}
+	}
+
+	// Check if the migration from legacy config fields has already been
+	// completed. See the Migrate* config fields for more details.
+	migrationCompleteFilePath := filepath.Join(config.GetPsiphonDataDirectory(), "migration_complete")
+	needMigration := !common.FileExists(migrationCompleteFilePath)
+
+	// Collect notices to emit them after notice files are set
+	var noticeMigrationAlertMsgs []string
+	var noticeMigrationInfoMsgs []string
+
+	// Migrate notices first to ensure notice files are used for notices if
+	// UseNoticeFiles is set.
+	homepageFilePath := config.GetHomePageFilename()
+	noticesFilePath := config.GetNoticesFilename()
+
+	if needMigration {
+
+		// Move notice files that exist at legacy file paths under the data root
+		// directory.
+
+		noticeMigrationInfoMsgs = append(noticeMigrationInfoMsgs, "Config migration: need migration")
+		noticeMigrations := migrationsFromLegacyNoticeFilePaths(config)
+
+		for _, migration := range noticeMigrations {
+			err := common.DoFileMigration(migration)
+			if err != nil {
+				alertMsg := fmt.Sprintf("Config migration: %s", errors.Trace(err))
+				noticeMigrationAlertMsgs = append(noticeMigrationAlertMsgs, alertMsg)
+			} else {
+				infoMsg := fmt.Sprintf("Config migration: moved %s to %s", migration.OldPath, migration.NewPath)
+				noticeMigrationInfoMsgs = append(noticeMigrationInfoMsgs, infoMsg)
+			}
+		}
+	} else {
+		noticeMigrationInfoMsgs = append(noticeMigrationInfoMsgs, "Config migration: migration already completed")
+	}
+
+	if config.UseNoticeFiles != nil {
+		setNoticeFiles(
+			homepageFilePath,
+			noticesFilePath,
+			config.UseNoticeFiles.RotatingFileSize,
+			config.UseNoticeFiles.RotatingSyncFrequency)
+	}
+
+	// Emit notices now that notice files are set if configured
+	for _, msg := range noticeMigrationAlertMsgs {
+		NoticeAlert(msg)
+	}
+	for _, msg := range noticeMigrationInfoMsgs {
+		NoticeInfo(msg)
+	}
+
 	// Promote legacy fields.
 
 	if config.CustomHeaders == nil {
@@ -652,14 +900,49 @@ func (config *Config) Commit() error {
 		config.LimitTunnelProtocols = []string{config.TunnelProtocol}
 	}
 
+	if config.DataStoreDirectory != "" && config.MigrateDataStoreDirectory == "" {
+		config.MigrateDataStoreDirectory = config.DataStoreDirectory
+	}
+
+	if config.RemoteServerListDownloadFilename != "" && config.MigrateRemoteServerListDownloadFilename == "" {
+		config.MigrateRemoteServerListDownloadFilename = config.RemoteServerListDownloadFilename
+	}
+
+	if config.ObfuscatedServerListDownloadDirectory != "" && config.MigrateObfuscatedServerListDownloadDirectory == "" {
+		config.MigrateObfuscatedServerListDownloadDirectory = config.ObfuscatedServerListDownloadDirectory
+	}
+
+	if config.UpgradeDownloadFilename != "" && config.MigrateUpgradeDownloadFilename == "" {
+		config.MigrateUpgradeDownloadFilename = config.UpgradeDownloadFilename
+	}
+
 	// Supply default values.
 
-	if config.DataStoreDirectory == "" {
-		wd, err := os.Getwd()
+	// Create datastore directory.
+	dataStoreDirectoryPath := config.GetDataStoreDirectory()
+	if !common.FileExists(dataStoreDirectoryPath) {
+		err := os.Mkdir(dataStoreDirectoryPath, os.ModePerm)
 		if err != nil {
-			return errors.Trace(err)
+			return errors.Tracef("failed to create datastore directory %s with error: %s", dataStoreDirectoryPath, err.Error())
+		}
+	}
+
+	// Create OSL directory.
+	oslDirectoryPath := config.GetObfuscatedServerListDownloadDirectory()
+	if !common.FileExists(oslDirectoryPath) {
+		err := os.Mkdir(oslDirectoryPath, os.ModePerm)
+		if err != nil {
+			return errors.Tracef("failed to create osl directory %s with error: %s", oslDirectoryPath, err.Error())
+		}
+	}
+
+	// Create tapdance directory
+	tapdanceDirectoryPath := config.GetTapdanceDirectory()
+	if !common.FileExists(tapdanceDirectoryPath) {
+		err := os.Mkdir(tapdanceDirectoryPath, os.ModePerm)
+		if err != nil {
+			return errors.Tracef("failed to create tapdance directory %s with error: %s", tapdanceDirectoryPath, err.Error())
 		}
-		config.DataStoreDirectory = wd
 	}
 
 	if config.ClientVersion == "" {
@@ -672,6 +955,10 @@ func (config *Config) Commit() error {
 
 	// Validate config fields.
 
+	if !common.FileExists(config.DataRootDirectory) {
+		return errors.Tracef("DataRootDirectory does not exist: %s", config.DataRootDirectory)
+	}
+
 	if config.PropagationChannelId == "" {
 		return errors.TraceNew("propagation channel ID is missing from the configuration file")
 	}
@@ -697,20 +984,13 @@ func (config *Config) Commit() error {
 			if config.RemoteServerListSignaturePublicKey == "" {
 				return errors.TraceNew("missing RemoteServerListSignaturePublicKey")
 			}
-			if config.RemoteServerListDownloadFilename == "" {
-				return errors.TraceNew("missing RemoteServerListDownloadFilename")
-			}
 		}
 
 		if config.ObfuscatedServerListRootURLs != nil {
 			if config.RemoteServerListSignaturePublicKey == "" {
 				return errors.TraceNew("missing RemoteServerListSignaturePublicKey")
 			}
-			if config.ObfuscatedServerListDownloadDirectory == "" {
-				return errors.TraceNew("missing ObfuscatedServerListDownloadDirectory")
-			}
 		}
-
 	}
 
 	if config.SplitTunnelRoutesURLFormat != "" {
@@ -726,9 +1006,6 @@ func (config *Config) Commit() error {
 		if config.UpgradeDownloadClientVersionHeader == "" {
 			return errors.TraceNew("missing UpgradeDownloadClientVersionHeader")
 		}
-		if config.UpgradeDownloadFilename == "" {
-			return errors.TraceNew("missing UpgradeDownloadFilename")
-		}
 	}
 
 	// This constraint is expected by logic in Controller.runTunnels().
@@ -811,6 +1088,50 @@ func (config *Config) Commit() error {
 
 	config.networkIDGetter = newLoggingNetworkIDGetter(networkIDGetter)
 
+	// Migrate from old config fields. This results in files being moved under
+	// a config specified data root directory.
+
+	// If unset, set MigrateDataStoreDirectory to the previous default value for
+	// DataStoreDirectory to ensure that datastore files are migrated.
+	if config.MigrateDataStoreDirectory == "" {
+		wd, err := os.Getwd()
+		if err != nil {
+			return errors.Trace(err)
+		}
+		NoticeInfo("MigrateDataStoreDirectory unset, using working directory %s", wd)
+		config.MigrateDataStoreDirectory = wd
+	}
+
+	if needMigration {
+
+		// Move files that exist at legacy file paths under the data root
+		// directory.
+
+		migrations, err := migrationsFromLegacyFilePaths(config)
+		if err != nil {
+			return errors.Trace(err)
+		}
+
+		// Do migrations
+
+		for _, migration := range migrations {
+			err := common.DoFileMigration(migration)
+			if err != nil {
+				NoticeAlert("Config migration: %s", errors.Trace(err))
+			} else {
+				NoticeInfo("Config migration: moved %s to %s", migration.OldPath, migration.NewPath)
+			}
+		}
+
+		f, err := os.Create(migrationCompleteFilePath)
+		if err != nil {
+			NoticeAlert("Config migration: failed to create %s with error %s", migrationCompleteFilePath, errors.Trace(err))
+		} else {
+			NoticeInfo("Config migration: completed")
+			f.Close()
+		}
+	}
+
 	config.committed = true
 
 	return nil
@@ -893,6 +1214,68 @@ func (config *Config) GetAuthorizations() []string {
 	return config.authorizations
 }
 
+// GetPsiphonDataDirectory returns the directory under which all persistent
+// files should be stored. This directory is created under
+// config.DataRootDirectory. The motivation for an additional directory is that
+// config.DataRootDirectory defaults to the current working directory, which may
+// include non-tunnel-core files that should be excluded from directory-spanning
+// operations (e.g. excluding all tunnel-core files from backup).
+func (config *Config) GetPsiphonDataDirectory() string {
+	return filepath.Join(config.DataRootDirectory, PsiphonDataDirectoryName)
+}
+
+// GetHomePageFilename the path where the homepage notices file will be created.
+func (config *Config) GetHomePageFilename() string {
+	return filepath.Join(config.GetPsiphonDataDirectory(), HomepageFilename)
+}
+
+// GetNoticesFilename returns the path where the notices file will be created.
+// When the file is rotated it will be moved to config.GetOldNoticesFilename().
+func (config *Config) GetNoticesFilename() string {
+	return filepath.Join(config.GetPsiphonDataDirectory(), NoticesFilename)
+}
+
+// GetOldNoticeFilename returns the path where the rotated notices file will be
+// created.
+func (config *Config) GetOldNoticesFilename() string {
+	return filepath.Join(config.GetPsiphonDataDirectory(), OldNoticesFilename)
+}
+
+// GetDataStoreDirectory returns the directory in which the persistent database
+// will be stored. Created in Config.Commit(). The persistent database contains
+// information such as server entries.
+func (config *Config) GetDataStoreDirectory() string {
+	return filepath.Join(config.GetPsiphonDataDirectory(), "datastore")
+}
+
+// GetObfuscatedServerListDownloadDirectory returns the directory in which
+// obfuscated remote server list downloads will be stored. Created in
+// Config.Commit().
+func (config *Config) GetObfuscatedServerListDownloadDirectory() string {
+	return filepath.Join(config.GetPsiphonDataDirectory(), "osl")
+}
+
+// GetRemoteServerListDownloadFilename returns the filename where the remote
+// server list download will be stored. Data is stored in co-located files
+// (RemoteServerListDownloadFilename.part*) to allow for resumable downloading.
+func (config *Config) GetRemoteServerListDownloadFilename() string {
+	return filepath.Join(config.GetPsiphonDataDirectory(), "remote_server_list")
+}
+
+// GetUpgradeDownloadFilename specifies the filename where upgrade downloads
+// will be stored. This filename is valid when UpgradeDownloadURLs
+// (or UpgradeDownloadUrl) is specified. Data is stored in co-located files
+// (UpgradeDownloadFilename.part*) to allow for resumable downloading.
+func (config *Config) GetUpgradeDownloadFilename() string {
+	return filepath.Join(config.GetPsiphonDataDirectory(), UpgradeDownloadFilename)
+}
+
+// GetTapdanceDirectory returns the directory under which tapdance will create
+// and manage files.
+func (config *Config) GetTapdanceDirectory() string {
+	return filepath.Join(config.GetPsiphonDataDirectory(), "tapdance")
+}
+
 // UseUpstreamProxy indicates if an upstream proxy has been
 // configured.
 func (config *Config) UseUpstreamProxy() bool {
@@ -1381,3 +1764,147 @@ func (n *loggingNetworkIDGetter) GetNetworkID() string {
 
 	return networkID
 }
+
+// migrationsFromLegacyNoticeFilePaths returns the file migrations which must be
+// performed to move notice files from legacy file paths, which were configured
+// with the legacy config fields HomepageNoticesFilename and
+// RotatingNoticesFilename, to the new file paths used by Psiphon which exist
+// under the data root directory.
+func migrationsFromLegacyNoticeFilePaths(config *Config) []common.FileMigration {
+	var noticeMigrations []common.FileMigration
+
+	if config.MigrateHompageNoticesFilename != "" {
+		noticeMigrations = append(noticeMigrations, common.FileMigration{
+			OldPath: config.MigrateHompageNoticesFilename,
+			NewPath: config.GetHomePageFilename(),
+		})
+	}
+
+	if config.MigrateRotatingNoticesFilename != "" {
+		migrations := []common.FileMigration{
+			{
+				OldPath: config.MigrateRotatingNoticesFilename,
+				NewPath: config.GetNoticesFilename(),
+				IsDir:   false,
+			},
+			{
+				OldPath: config.MigrateRotatingNoticesFilename + ".1",
+				NewPath: config.GetNoticesFilename() + ".1",
+			},
+		}
+		noticeMigrations = append(noticeMigrations, migrations...)
+	}
+
+	return noticeMigrations
+}
+
+// migrationsFromLegacyFilePaths returns the file migrations which must be
+// performed to move files from legacy file paths, which were configured with
+// legacy config fields, to the new file paths used by Psiphon which exist
+// under the data root directory.
+func migrationsFromLegacyFilePaths(config *Config) ([]common.FileMigration, error) {
+
+	migrations := []common.FileMigration{
+		{
+			OldPath: filepath.Join(config.MigrateDataStoreDirectory, "psiphon.boltdb"),
+			NewPath: filepath.Join(config.GetDataStoreDirectory(), "psiphon.boltdb"),
+		},
+		{
+			OldPath: filepath.Join(config.MigrateDataStoreDirectory, "psiphon.boltdb.lock"),
+			NewPath: filepath.Join(config.GetDataStoreDirectory(), "psiphon.boltdb.lock"),
+		},
+		{
+			OldPath: filepath.Join(config.MigrateDataStoreDirectory, "tapdance"),
+			NewPath: filepath.Join(config.GetTapdanceDirectory(), "tapdance"),
+			IsDir:   true,
+		},
+	}
+
+	if config.MigrateRemoteServerListDownloadFilename != "" {
+
+		// Migrate remote server list files
+
+		rslMigrations := []common.FileMigration{
+			{
+				OldPath: config.MigrateRemoteServerListDownloadFilename,
+				NewPath: config.GetRemoteServerListDownloadFilename(),
+			},
+			{
+				OldPath: config.MigrateRemoteServerListDownloadFilename + ".part",
+				NewPath: config.GetRemoteServerListDownloadFilename() + ".part",
+			},
+			{
+				OldPath: config.MigrateRemoteServerListDownloadFilename + ".part.etag",
+				NewPath: config.GetRemoteServerListDownloadFilename() + ".part.etag",
+			},
+		}
+
+		migrations = append(migrations, rslMigrations...)
+	}
+
+	if config.MigrateObfuscatedServerListDownloadDirectory != "" {
+
+		// Migrate OSL registry file and downloads
+
+		oslFileRegex, err := regexp.Compile(`^osl-.+$`)
+		if err != nil {
+			return nil, errors.TraceMsg(err, "failed to compile regex for osl files")
+		}
+
+		files, err := ioutil.ReadDir(config.MigrateObfuscatedServerListDownloadDirectory)
+		if err != nil {
+			NoticeAlert("Migration: failed to read directory %s with error %s", config.MigrateObfuscatedServerListDownloadDirectory, err)
+		} else {
+			for _, file := range files {
+				if oslFileRegex.MatchString(file.Name()) {
+					fileMigration := common.FileMigration{
+						OldPath: filepath.Join(config.MigrateObfuscatedServerListDownloadDirectory, file.Name()),
+						NewPath: filepath.Join(config.GetObfuscatedServerListDownloadDirectory(), file.Name()),
+					}
+					migrations = append(migrations, fileMigration)
+				}
+			}
+		}
+	}
+
+	if config.MigrateUpgradeDownloadFilename != "" {
+
+		// Migrate downloaded upgrade files
+
+		oldUpgradeDownloadFilename := filepath.Base(config.MigrateUpgradeDownloadFilename)
+
+		// Create regex for:
+		// <old_upgrade_download_filename>
+		// <old_upgrade_download_filename>.<client_version_number>
+		// <old_upgrade_download_filename>.<client_version_number>.part
+		// <old_upgrade_download_filename>.<client_version_number>.part.etag
+		upgradeDownloadFileRegex, err := regexp.Compile(`^` + oldUpgradeDownloadFilename + `(\.\d+(\.part(\.etag)?)?)?$`)
+		if err != nil {
+			return nil, errors.TraceMsg(err, "failed to compile regex for upgrade files")
+		}
+
+		upgradeDownloadDir := filepath.Dir(config.MigrateUpgradeDownloadFilename)
+
+		files, err := ioutil.ReadDir(upgradeDownloadDir)
+		if err != nil {
+			NoticeAlert("Migration: failed to read directory %s with error %s", upgradeDownloadDir, err)
+		} else {
+
+			for _, file := range files {
+
+				if upgradeDownloadFileRegex.MatchString(file.Name()) {
+
+					oldFileSuffix := strings.TrimPrefix(file.Name(), oldUpgradeDownloadFilename)
+
+					fileMigration := common.FileMigration{
+						OldPath: filepath.Join(upgradeDownloadDir, file.Name()),
+						NewPath: config.GetUpgradeDownloadFilename() + oldFileSuffix,
+					}
+					migrations = append(migrations, fileMigration)
+				}
+			}
+		}
+	}
+
+	return migrations, nil
+}

+ 417 - 1
psiphon/config_test.go

@@ -22,9 +22,13 @@ package psiphon
 import (
 	"encoding/json"
 	"io/ioutil"
+	"os"
+	"path/filepath"
 	"strings"
 	"testing"
 
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 	"github.com/stretchr/testify/suite"
 )
 
@@ -39,6 +43,7 @@ type ConfigTestSuite struct {
 	confStubBlob      []byte
 	requiredFields    []string
 	nonRequiredFields []string
+	testDirectory     string
 }
 
 func (suite *ConfigTestSuite) SetupSuite() {
@@ -52,8 +57,26 @@ func (suite *ConfigTestSuite) SetupSuite() {
 
 	var obj map[string]interface{}
 	json.Unmarshal(suite.confStubBlob, &obj)
+
+	// Use a temporary directory for the data root directory so any artifacts
+	// created by config.Commit() can be cleaned up.
+
+	testDirectory, err := ioutil.TempDir("", "psiphon-config-test")
+	if err != nil {
+		suite.T().Fatalf("TempDir failed: %s\n", err)
+	}
+	suite.testDirectory = testDirectory
+	obj["DataRootDirectory"] = testDirectory
+
+	suite.confStubBlob, err = json.Marshal(obj)
+	if err != nil {
+		suite.T().Fatalf("Marshal failed: %s\n", err)
+	}
+
 	for k, v := range obj {
-		if v == "<placeholder>" {
+		if k == "DataRootDirectory" {
+			// skip
+		} else if v == "<placeholder>" {
 			suite.requiredFields = append(suite.requiredFields, k)
 		} else {
 			suite.nonRequiredFields = append(suite.nonRequiredFields, k)
@@ -61,6 +84,17 @@ func (suite *ConfigTestSuite) SetupSuite() {
 	}
 }
 
+func (suite *ConfigTestSuite) TearDownSuite() {
+	if common.FileExists(suite.testDirectory) {
+		err := os.RemoveAll(suite.testDirectory)
+		if err != nil {
+			suite.T().Fatalf("Failed to remove test directory %s: %s", suite.testDirectory, err.Error())
+		}
+	} else {
+		suite.T().Fatalf("Test directory not found: %s", suite.testDirectory)
+	}
+}
+
 func TestConfigTestSuite(t *testing.T) {
 	suite.Run(t, new(ConfigTestSuite))
 }
@@ -187,3 +221,385 @@ func (suite *ConfigTestSuite) Test_LoadConfig_GoodJson() {
 	}
 	suite.Nil(err, "JSON with null for optional values should succeed")
 }
+
+// Test when migrating from old config fields results in filesystem changes.
+func (suite *ConfigTestSuite) Test_LoadConfig_Migrate() {
+
+	// This test needs its own temporary directory because a previous test may
+	// have paved the file which signals that migration has already been
+	// completed.
+	testDirectory, err := ioutil.TempDir("", "psiphon-config-migration-test")
+	if err != nil {
+		suite.T().Fatalf("TempDir failed: %s\n", err)
+	}
+
+	defer func() {
+		if common.FileExists(testDirectory) {
+			err := os.RemoveAll(testDirectory)
+			if err != nil {
+				suite.T().Fatalf("Failed to remove test directory %s: %s", testDirectory, err.Error())
+			}
+		}
+	}()
+
+	// Pre migration files and directories
+	oldDataStoreDirectory := filepath.Join(testDirectory, "datastore_old")
+	oldRemoteServerListname := "rsl"
+	oldObfuscatedServerListDirectoryName := "obfuscated_server_list"
+	oldObfuscatedServerListDirectory := filepath.Join(testDirectory, oldObfuscatedServerListDirectoryName)
+	oldUpgradeDownloadFilename := "upgrade"
+	oldRotatingNoticesFilename := "rotating_notices"
+	oldHomepageNoticeFilename := "homepage"
+
+	// Post migration data root directory
+	testDataRootDirectory := filepath.Join(testDirectory, "data_root_directory")
+
+	oldFileTree := FileTree{
+		Name: testDirectory,
+		Children: []FileTree{
+			{
+				Name: "datastore_old",
+				Children: []FileTree{
+					{
+						Name: "psiphon.boltdb",
+					},
+					{
+						Name: "psiphon.boltdb.lock",
+					},
+					{
+						Name: "tapdance",
+						Children: []FileTree{
+							{
+								Name: "file1",
+							},
+							{
+								Name: "file2",
+							},
+						},
+					},
+					{
+						Name: "non_tunnel_core_file_should_not_be_migrated",
+					},
+				},
+			},
+			{
+				Name: oldRemoteServerListname,
+			},
+			{
+				Name: oldRemoteServerListname + ".part",
+			},
+			{
+				Name: oldRemoteServerListname + ".part.etag",
+			},
+			{
+				Name: oldObfuscatedServerListDirectoryName,
+				Children: []FileTree{
+					{
+						Name: "osl-registry",
+					},
+					{
+						Name: "osl-registry.cached",
+					},
+					{
+						Name: "osl-1",
+					},
+					{
+						Name: "osl-1.part",
+					},
+				},
+			},
+			{
+				Name: oldRotatingNoticesFilename,
+			},
+			{
+				Name: oldRotatingNoticesFilename + ".1",
+			},
+			{
+				Name: oldHomepageNoticeFilename,
+			},
+			{
+				Name: oldUpgradeDownloadFilename,
+			},
+			{
+				Name: oldUpgradeDownloadFilename + ".1234",
+			},
+			{
+				Name: oldUpgradeDownloadFilename + ".1234.part",
+			},
+			{
+				Name: oldUpgradeDownloadFilename + ".1234.part.etag",
+			},
+			{
+				Name: "data_root_directory",
+				Children: []FileTree{
+					{
+						Name: "non_tunnel_core_file_should_not_be_clobbered",
+					},
+				},
+			},
+		},
+	}
+
+	// Write test files
+	traverseFileTree(func(tree FileTree, path string) {
+		if tree.Children == nil || len(tree.Children) == 0 {
+			if !common.FileExists(path) {
+				f, err := os.Create(path)
+				if err != nil {
+					suite.T().Fatalf("Failed to create test file %s with error: %s", path, err.Error())
+				}
+				f.Close()
+			}
+		} else {
+			if !common.FileExists(path) {
+				err := os.Mkdir(path, os.ModePerm)
+				if err != nil {
+					suite.T().Fatalf("Failed to create test directory %s with error: %s", path, err.Error())
+				}
+			}
+		}
+	}, "", oldFileTree)
+
+	// Create config with legacy config values
+	config := &Config{
+		DataRootDirectory:                            testDataRootDirectory,
+		MigrateRotatingNoticesFilename:               filepath.Join(testDirectory, oldRotatingNoticesFilename),
+		MigrateHompageNoticesFilename:                filepath.Join(testDirectory, oldHomepageNoticeFilename),
+		MigrateDataStoreDirectory:                    oldDataStoreDirectory,
+		PropagationChannelId:                         "ABCDEFGH",
+		SponsorId:                                    "12345678",
+		LocalSocksProxyPort:                          0,
+		LocalHttpProxyPort:                           0,
+		MigrateRemoteServerListDownloadFilename:      filepath.Join(testDirectory, oldRemoteServerListname),
+		MigrateObfuscatedServerListDownloadDirectory: oldObfuscatedServerListDirectory,
+		MigrateUpgradeDownloadFilename:               filepath.Join(testDirectory, oldUpgradeDownloadFilename),
+	}
+
+	// Commit config, this is where file migration happens
+	err = config.Commit()
+	if err != nil {
+		suite.T().Fatal("Error committing config:", err)
+		return
+	}
+
+	expectedNewTree := FileTree{
+		Name: testDirectory,
+		Children: []FileTree{
+			{
+				Name: "data_root_directory",
+				Children: []FileTree{
+					{
+						Name: "non_tunnel_core_file_should_not_be_clobbered",
+					},
+					{
+						Name: "ca.psiphon.PsiphonTunnel.tunnel-core",
+						Children: []FileTree{
+							{
+								Name: "migration_complete",
+							},
+							{
+								Name: "remote_server_list",
+							},
+							{
+								Name: "remote_server_list.part",
+							},
+							{
+								Name: "remote_server_list.part.etag",
+							},
+							{
+								Name: "datastore",
+								Children: []FileTree{
+									{
+										Name: "psiphon.boltdb",
+									},
+									{
+										Name: "psiphon.boltdb.lock",
+									},
+								},
+							},
+							{
+								Name: "osl",
+								Children: []FileTree{
+									{
+										Name: "osl-registry",
+									},
+									{
+										Name: "osl-registry.cached",
+									},
+									{
+										Name: "osl-1",
+									},
+									{
+										Name: "osl-1.part",
+									},
+								},
+							},
+							{
+								Name: "tapdance",
+								Children: []FileTree{
+									{
+										Name: "tapdance",
+										Children: []FileTree{
+											{
+												Name: "file1",
+											},
+											{
+												Name: "file2",
+											},
+										},
+									},
+								},
+							},
+							{
+								Name: "upgrade",
+							},
+							{
+								Name: "upgrade.1234",
+							},
+							{
+								Name: "upgrade.1234.part",
+							},
+							{
+								Name: "upgrade.1234.part.etag",
+							},
+							{
+								Name: "notices",
+							},
+							{
+								Name: "notices.1",
+							},
+							{
+								Name: "homepage",
+							},
+						},
+					},
+				},
+			},
+			{
+				Name: "datastore_old",
+				Children: []FileTree{
+					{
+						Name: "non_tunnel_core_file_should_not_be_migrated",
+					},
+				},
+			},
+			{
+				Name: oldObfuscatedServerListDirectoryName,
+			},
+		},
+	}
+
+	// Read the test directory into a file tree
+	testDirectoryTree, err := buildDirectoryTree("", testDirectory)
+	if err != nil {
+		suite.T().Fatal("Failed to build directory tree:", err)
+	}
+
+	// Enumerate the file paths, relative to the test directory,
+	// of each file in the test directory after migration.
+	testDirectoryFilePaths := make(map[string]int)
+	traverseFileTree(func(tree FileTree, path string) {
+		if val, ok := testDirectoryFilePaths[path]; ok {
+			testDirectoryFilePaths[path] = val + 1
+		} else {
+			testDirectoryFilePaths[path] = 1
+		}
+	}, "", *testDirectoryTree)
+
+	// Enumerate the file paths, relative to the test directory,
+	// of each file we expect to exist in the test directory tree
+	// after migration.
+	expectedTestDirectoryFilePaths := make(map[string]int)
+	traverseFileTree(func(tree FileTree, path string) {
+		if val, ok := expectedTestDirectoryFilePaths[path]; ok {
+			expectedTestDirectoryFilePaths[path] = val + 1
+		} else {
+			expectedTestDirectoryFilePaths[path] = 1
+		}
+	}, "", expectedNewTree)
+
+	// The set of expected file paths and set of actual  file paths should be
+	// identical.
+
+	for k, _ := range expectedTestDirectoryFilePaths {
+		_, ok := testDirectoryFilePaths[k]
+		if ok {
+			// Prevent redundant checks
+			delete(testDirectoryFilePaths, k)
+		} else {
+			suite.T().Errorf("Expected %s to exist in directory", k)
+		}
+	}
+
+	for k, _ := range testDirectoryFilePaths {
+		if _, ok := expectedTestDirectoryFilePaths[k]; !ok {
+			suite.T().Errorf("%s in directory but not expected", k)
+		}
+	}
+}
+
+// FileTree represents a file or directory in a file tree.
+// There is no need to distinguish between the two in our tests.
+type FileTree struct {
+	Name     string
+	Children []FileTree
+}
+
+// traverseFileTree traverses a file tree and emits the filepath of each node.
+//
+// For example:
+//
+//   a
+//   ├── b
+//   │   ├── 1
+//   │   └── 2
+//   └── c
+//       └── 3
+//
+// Will result in: ["a", "a/b", "a/b/1", "a/b/2", "a/c", "a/c/3"].
+func traverseFileTree(f func(node FileTree, nodePath string), basePath string, tree FileTree) {
+	filePath := filepath.Join(basePath, tree.Name)
+	f(tree, filePath)
+	if tree.Children == nil || len(tree.Children) == 0 {
+		return
+	}
+	for _, childTree := range tree.Children {
+		traverseFileTree(f, filePath, childTree)
+	}
+}
+
+// buildDirectoryTree creates a file tree, with the given directory as its root,
+// representing the directory structure that exists relative to the given directory.
+func buildDirectoryTree(basePath, directoryName string) (*FileTree, error) {
+
+	tree := &FileTree{
+		Name:     directoryName,
+		Children: nil,
+	}
+
+	dirPath := filepath.Join(basePath, directoryName)
+	files, err := ioutil.ReadDir(dirPath)
+	if err != nil {
+		return nil, errors.Tracef("Failed to read directory %s with error: %s", dirPath, err.Error())
+	}
+
+	if len(files) > 0 {
+		for _, file := range files {
+			if file.IsDir() {
+				filePath := filepath.Join(basePath, directoryName)
+				childTree, err := buildDirectoryTree(filePath, file.Name())
+				if err != nil {
+					return nil, err
+				}
+				tree.Children = append(tree.Children, *childTree)
+			} else {
+				tree.Children = append(tree.Children, FileTree{
+					Name:     file.Name(),
+					Children: nil,
+				})
+			}
+		}
+	}
+
+	return tree, nil
+}

+ 1 - 4
psiphon/controller_test.go

@@ -31,7 +31,6 @@ import (
 	"net/http"
 	"net/url"
 	"os"
-	"path/filepath"
 	"strings"
 	"sync"
 	"sync/atomic"
@@ -485,9 +484,7 @@ func controllerRun(t *testing.T, runConfig *controllerRunConfig) {
 
 	var modifyConfig map[string]interface{}
 	json.Unmarshal(configJSON, &modifyConfig)
-	modifyConfig["DataStoreDirectory"] = testDataDirName
-	modifyConfig["RemoteServerListDownloadFilename"] = filepath.Join(testDataDirName, "server_list_compressed")
-	modifyConfig["UpgradeDownloadFilename"] = filepath.Join(testDataDirName, "upgrade")
+	modifyConfig["DataRootDirectory"] = testDataDirName
 
 	if runConfig.protocol != "" {
 		modifyConfig["LimitTunnelProtocols"] = protocol.TunnelProtocols{runConfig.protocol}

+ 1 - 1
psiphon/dataStore.go

@@ -69,7 +69,7 @@ func OpenDataStore(config *Config) error {
 		return errors.TraceNew("db already open")
 	}
 
-	newDB, err := datastoreOpenDB(config.DataStoreDirectory)
+	newDB, err := datastoreOpenDB(config.GetDataStoreDirectory())
 	if err != nil {
 		datastoreMutex.Unlock()
 		return errors.Trace(err)

+ 2 - 2
psiphon/dataStoreRecovery_test.go

@@ -48,7 +48,7 @@ func TestBoltResiliency(t *testing.T) {
 
 	clientConfigJSONTemplate := `
     {
-        "DataStoreDirectory" : "%s",
+        "DataRootDirectory" : "%s",
         "ClientPlatform" : "",
         "ClientVersion" : "0",
         "SponsorId" : "0",
@@ -190,7 +190,7 @@ func TestBoltResiliency(t *testing.T) {
 	}
 
 	truncateDataStore := func() {
-		filename := filepath.Join(testDataDirName, "psiphon.boltdb")
+		filename := filepath.Join(testDataDirName, "ca.psiphon.PsiphonTunnel.tunnel-core", "datastore", "psiphon.boltdb")
 		configFile, err := os.OpenFile(filename, os.O_RDWR, 0666)
 		if err != nil {
 			t.Fatalf("OpenFile failed: %s", err)

+ 1 - 1
psiphon/dialParameters_test.go

@@ -67,7 +67,7 @@ func runDialParametersAndReplay(t *testing.T, tunnelProtocol string) {
 	clientConfig := &Config{
 		PropagationChannelId: "0",
 		SponsorId:            "0",
-		DataStoreDirectory:   testDataDirName,
+		DataRootDirectory:    testDataDirName,
 		NetworkIDGetter:      new(testNetworkGetter),
 	}
 

+ 1 - 1
psiphon/exchange_test.go

@@ -65,7 +65,7 @@ func TestServerEntryExchange(t *testing.T) {
 		    {
                 "SponsorId" : "0",
                 "PropagationChannelId" : "0",
-		        "DataStoreDirectory" : "%s",
+		        "DataRootDirectory" : "%s",
 		        "ServerEntrySignaturePublicKey" : "%s",
 		        "ExchangeObfuscationKey" : "%s",
 		        "NetworkID" : "%s"

+ 1 - 1
psiphon/limitProtocols_test.go

@@ -107,7 +107,7 @@ func TestLimitTunnelProtocols(t *testing.T) {
 		t.Fatalf("error processing configuration file: %s", err)
 	}
 
-	clientConfig.DataStoreDirectory = testDataDirName
+	clientConfig.DataRootDirectory = testDataDirName
 
 	err = clientConfig.Commit()
 	if err != nil {

+ 1 - 3
psiphon/memory_test/memory_test.go

@@ -99,9 +99,7 @@ func runMemoryTest(t *testing.T, testMode int) {
 	json.Unmarshal(configJSON, &modifyConfig)
 	modifyConfig["ClientVersion"] = "999999999"
 	modifyConfig["TunnelPoolSize"] = 1
-	modifyConfig["DataStoreDirectory"] = testDataDirName
-	modifyConfig["RemoteServerListDownloadFilename"] = filepath.Join(testDataDirName, "server_list_compressed")
-	modifyConfig["UpgradeDownloadFilename"] = filepath.Join(testDataDirName, "upgrade")
+	modifyConfig["DataRootDirectory"] = testDataDirName
 	modifyConfig["FetchRemoteServerListRetryPeriodMilliseconds"] = 250
 	modifyConfig["EstablishTunnelPausePeriodSeconds"] = 1
 	modifyConfig["ConnectionWorkerPoolSize"] = 10

+ 3 - 3
psiphon/notice.go

@@ -122,7 +122,7 @@ func SetNoticeWriter(writer io.Writer) {
 	singletonNoticeLogger.writer = writer
 }
 
-// SetNoticeFiles configures files for notice writing.
+// setNoticeFiles configures files for notice writing.
 //
 // - When homepageFilename is not "", homepages are written to the specified file
 //   and omitted from the writer. The file may be read after the Tunnels notice
@@ -140,10 +140,10 @@ func SetNoticeWriter(writer io.Writer) {
 // - If an error occurs when writing to a file, an InternalError notice is emitted to
 //   the writer.
 //
-// SetNoticeFiles closes open homepage or rotating files before applying the new
+// setNoticeFiles closes open homepage or rotating files before applying the new
 // configuration.
 //
-func SetNoticeFiles(
+func setNoticeFiles(
 	homepageFilename string,
 	rotatingFilename string,
 	rotatingFileSize int,

+ 7 - 7
psiphon/remoteServerList.go

@@ -41,7 +41,7 @@ type RemoteServerListFetcher func(
 // config.RemoteServerListURLs. It validates its digital signature using the
 // public key config.RemoteServerListSignaturePublicKey and parses the
 // data field into ServerEntry records.
-// config.RemoteServerListDownloadFilename is the location to store the
+// config.GetRemoteServerListDownloadFilename() is the location to store the
 // download. As the download is resumed after failure, this filename must
 // be unique and persistent.
 func FetchCommonRemoteServerList(
@@ -71,7 +71,7 @@ func FetchCommonRemoteServerList(
 		canonicalURL,
 		skipVerify,
 		"",
-		config.RemoteServerListDownloadFilename)
+		config.GetRemoteServerListDownloadFilename())
 	if err != nil {
 		return errors.Tracef("failed to download common remote server list: %s", errors.Trace(err))
 	}
@@ -81,7 +81,7 @@ func FetchCommonRemoteServerList(
 		return nil
 	}
 
-	file, err := os.Open(config.RemoteServerListDownloadFilename)
+	file, err := os.Open(config.GetRemoteServerListDownloadFilename())
 	if err != nil {
 		return errors.Tracef("failed to open common remote server list: %s", errors.Trace(err))
 
@@ -124,8 +124,8 @@ func FetchCommonRemoteServerList(
 // individual download fails, the fetch proceeds if it can.
 // Authenticated package digital signatures are validated using the
 // public key config.RemoteServerListSignaturePublicKey.
-// config.ObfuscatedServerListDownloadDirectory is the location to store the
-// downloaded files. As  downloads are resumed after failure, this directory
+// config.GetObfuscatedServerListDownloadDirectory() is the location to store
+// the downloaded files. As  downloads are resumed after failure, this directory
 // must be unique and persistent.
 func FetchObfuscatedServerLists(
 	ctx context.Context,
@@ -146,7 +146,7 @@ func FetchObfuscatedServerLists(
 	downloadURL := osl.GetOSLRegistryURL(rootURL)
 	canonicalURL := osl.GetOSLRegistryURL(canonicalRootURL)
 
-	downloadFilename := osl.GetOSLRegistryFilename(config.ObfuscatedServerListDownloadDirectory)
+	downloadFilename := osl.GetOSLRegistryFilename(config.GetObfuscatedServerListDownloadDirectory())
 	cachedFilename := downloadFilename + ".cached"
 
 	// If the cached registry is not present, we need to download or resume downloading
@@ -239,7 +239,7 @@ func FetchObfuscatedServerLists(
 		}
 
 		downloadFilename := osl.GetOSLFilename(
-			config.ObfuscatedServerListDownloadDirectory, oslFileSpec.ID)
+			config.GetObfuscatedServerListDownloadDirectory(), oslFileSpec.ID)
 
 		downloadURL := osl.GetOSLFileURL(rootURL, oslFileSpec.ID)
 		canonicalURL := osl.GetOSLFileURL(canonicalRootURL, oslFileSpec.ID)

+ 11 - 9
psiphon/remoteServerList_test.go

@@ -200,7 +200,16 @@ func testObfuscatedRemoteServerLists(t *testing.T, omitMD5Sums bool) {
 	// mock seeding SLOKs
 	//
 
-	err = OpenDataStore(&Config{DataStoreDirectory: testDataDirName})
+	config := Config{
+		DataRootDirectory:    testDataDirName,
+		PropagationChannelId: "0",
+		SponsorId:            "0"}
+	err = config.Commit()
+	if err != nil {
+		t.Fatalf("Error initializing config: %s", err)
+	}
+
+	err = OpenDataStore(&config)
 	if err != nil {
 		t.Fatalf("error initializing client datastore: %s", err)
 	}
@@ -240,7 +249,6 @@ func testObfuscatedRemoteServerLists(t *testing.T, omitMD5Sums bool) {
 
 	// The common remote server list fetches will 404
 	remoteServerListURL := fmt.Sprintf("http://%s/server_list_compressed", remoteServerListHostAddresses[0])
-	remoteServerListDownloadFilename := filepath.Join(testDataDirName, "server_list_compressed")
 
 	obfuscatedServerListRootURLsJSONConfig := "["
 	obfuscatedServerListRootURLs := make([]string, len(remoteServerListHostAddresses))
@@ -287,8 +295,6 @@ func testObfuscatedRemoteServerLists(t *testing.T, omitMD5Sums bool) {
 		}
 	}()
 
-	obfuscatedServerListDownloadDirectory := testDataDirName
-
 	//
 	// run Psiphon server
 	//
@@ -371,7 +377,7 @@ func testObfuscatedRemoteServerLists(t *testing.T, omitMD5Sums bool) {
 	// Note: calling LoadConfig ensures all *int config fields are initialized
 	clientConfigJSONTemplate := `
     {
-        "DataStoreDirectory" : "%s",
+        "DataRootDirectory" : "%s",
         "ClientPlatform" : "",
         "ClientVersion" : "0",
         "SponsorId" : "0",
@@ -381,9 +387,7 @@ func testObfuscatedRemoteServerLists(t *testing.T, omitMD5Sums bool) {
         "FetchRemoteServerListRetryPeriodMilliseconds" : 250,
 		"RemoteServerListSignaturePublicKey" : "%s",
 		"RemoteServerListUrl" : "%s",
-		"RemoteServerListDownloadFilename" : "%s",
 		"ObfuscatedServerListRootURLs" : %s,
-		"ObfuscatedServerListDownloadDirectory" : "%s",
 		"UpstreamProxyUrl" : "%s"
     }`
 
@@ -392,9 +396,7 @@ func testObfuscatedRemoteServerLists(t *testing.T, omitMD5Sums bool) {
 		testDataDirName,
 		signingPublicKey,
 		remoteServerListURL,
-		remoteServerListDownloadFilename,
 		obfuscatedServerListRootURLsJSONConfig,
-		obfuscatedServerListDownloadDirectory,
 		disruptorProxyURL)
 
 	clientConfig, err := LoadConfig([]byte(clientConfigJSON))

+ 1 - 1
psiphon/server/sessionID_test.go

@@ -125,7 +125,7 @@ func TestDuplicateSessionID(t *testing.T) {
 
 	clientConfigJSONTemplate := `
     {
-        "DataStoreDirectory" : "%s",
+        "DataRootDirectory" : "%s",
         "SponsorId" : "0",
         "PropagationChannelId" : "0",
         "SessionID" : "00000000000000000000000000000000"

+ 1 - 1
psiphon/tunnel.go

@@ -643,7 +643,7 @@ func dialTunnel(
 		dialConn, err = tapdance.Dial(
 			ctx,
 			config.EmitTapdanceLogs,
-			config.DataStoreDirectory,
+			config.GetTapdanceDirectory(),
 			NewNetDialer(dialParams.GetDialConfig()),
 			dialParams.DirectDialAddress)
 		if err != nil {

+ 10 - 11
psiphon/upgradeDownload.go

@@ -34,7 +34,7 @@ import (
 //
 // While downloading/resuming, a temporary file is used. Once the download is complete,
 // a notice is issued and the upgrade is available at the destination specified in
-// config.UpgradeDownloadFilename.
+// config.GetUpgradeDownloadFilename().
 //
 // The upgrade download may be either tunneled or untunneled. As the untunneled case may
 // happen with no handshake request response, the downloader cannot rely on having the
@@ -43,14 +43,13 @@ import (
 // remote entity's UpgradeDownloadClientVersionHeader. A HEAD request is made to check the
 // version before proceeding with a full download.
 //
-// NOTE: This code does not check that any existing file at config.UpgradeDownloadFilename
+// NOTE: This code does not check that any existing file at config.GetUpgradeDownloadFilename()
 // is actually the version specified in handshakeVersion.
 //
-// TODO: This logic requires the outer client to *omit* config.UpgradeDownloadFilename
 // when there's already a downloaded upgrade pending. Because the outer client currently
 // handles the authenticated package phase, and because the outer client deletes the
-// intermediate files (including config.UpgradeDownloadFilename), if the outer client
-// does not omit config.UpgradeDownloadFilename then the new version will be downloaded
+// intermediate files (including config.GetUpgradeDownloadFilename()), if the outer client
+// does not omit config.GetUpgradeDownloadFilename() then the new version will be downloaded
 // repeatedly. Implement a new scheme where tunnel core does the authenticated package phase
 // and tracks the the output by version number so that (a) tunnel core knows when it's not
 // necessary to re-download; (b) newer upgrades will be downloaded even when an older
@@ -68,8 +67,8 @@ func DownloadUpgrade(
 
 	// Check if complete file already downloaded
 
-	if _, err := os.Stat(config.UpgradeDownloadFilename); err == nil {
-		NoticeClientUpgradeDownloaded(config.UpgradeDownloadFilename)
+	if _, err := os.Stat(config.GetUpgradeDownloadFilename()); err == nil {
+		NoticeClientUpgradeDownloaded(config.GetUpgradeDownloadFilename())
 		return nil
 	}
 
@@ -152,10 +151,10 @@ func DownloadUpgrade(
 	// Proceed with download
 
 	// An intermediate filename is used since the presence of
-	// config.UpgradeDownloadFilename indicates a completed download.
+	// config.GetUpgradeDownloadFilename() indicates a completed download.
 
 	downloadFilename := fmt.Sprintf(
-		"%s.%s", config.UpgradeDownloadFilename, availableClientVersion)
+		"%s.%s", config.GetUpgradeDownloadFilename(), availableClientVersion)
 
 	n, _, err := ResumeDownload(
 		ctx,
@@ -171,12 +170,12 @@ func DownloadUpgrade(
 		return errors.Trace(err)
 	}
 
-	err = os.Rename(downloadFilename, config.UpgradeDownloadFilename)
+	err = os.Rename(downloadFilename, config.GetUpgradeDownloadFilename())
 	if err != nil {
 		return errors.Trace(err)
 	}
 
-	NoticeClientUpgradeDownloaded(config.UpgradeDownloadFilename)
+	NoticeClientUpgradeDownloaded(config.GetUpgradeDownloadFilename())
 
 	return nil
 }

+ 1 - 1
psiphon/userAgent_test.go

@@ -207,7 +207,7 @@ func attemptConnectionsWithUserAgent(
 
 	clientConfig.TargetServerEntry = string(encodedServerEntry)
 	clientConfig.TunnelProtocol = tunnelProtocol
-	clientConfig.DataStoreDirectory = testDataDirName
+	clientConfig.DataRootDirectory = testDataDirName
 
 	err = clientConfig.Commit()
 	if err != nil {