瀏覽代碼

Add Persistent Storage Support to V2Ray (#3300)

* update protogen to strip unused part

* add persistent storage support

* fix coding style

* update linter setting

* update github integration
Xiaokang Wang (Shelikhoo) 8 月之前
父節點
當前提交
78cd513b82

+ 3 - 3
.github/linters/.golangci.yml

@@ -1,8 +1,5 @@
 run:
   timeout: 5m
-  skip-files:
-    - generated.*
-    - .pb.go
 
 issues:
   new: true
@@ -13,6 +10,9 @@ issues:
     - linters:
         - stylecheck
       text: "ST1016:"
+  exclude-files:
+    - generated.*
+    - .pb.go
 
 linters:
   enable:

+ 1 - 1
.github/workflows/deb.yml

@@ -53,7 +53,7 @@ jobs:
           cp ../*.deb ./
 
       - name: Upload artifact
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         with:
           name: v2ray-debian-packages
           path: ./*.deb

+ 5 - 5
.github/workflows/release.yml

@@ -188,7 +188,7 @@ jobs:
           openssl dgst -sha512 $FILE | sed 's/([^)]*)//g' >>$DGST
 
       - name: Upload ZIP file to Artifacts
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         with:
           name: v2ray-${{ steps.get_filename.outputs.ASSET_NAME }}.zip
           path: v2ray-${{ steps.get_filename.outputs.ASSET_NAME }}.zip
@@ -217,7 +217,7 @@ jobs:
         with:
           go-version: ^1.23
 
-      - uses: actions/download-artifact@v3
+      - uses: actions/download-artifact@v4
         with:
           path: build_artifacts
 
@@ -252,17 +252,17 @@ jobs:
           openssl dgst -sha256 $FILE | sed 's/([^)]*)//g' >>$DGST
           openssl dgst -sha512 $FILE | sed 's/([^)]*)//g' >>$DGST
 
-      - uses: actions/upload-artifact@v3
+      - uses: actions/upload-artifact@v4
         with:
           name: Release.unsigned
           path: build_artifacts/Release.unsigned
 
-      - uses: actions/upload-artifact@v3
+      - uses: actions/upload-artifact@v4
         with:
           name: Release.unsigned.dgst
           path: build_artifacts/Release.unsigned.dgst
 
-      - uses: actions/upload-artifact@v3
+      - uses: actions/upload-artifact@v4
         with:
           name: v2ray-extra.zip
           path: build_artifacts/v2ray-extra.zip

+ 29 - 17
app/observatory/config.pb.go

@@ -365,11 +365,12 @@ func (x *Intensity) GetProbeInterval() uint32 {
 type Config struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
 	// @Document The selectors for outbound under observation
-	SubjectSelector []string `protobuf:"bytes,2,rep,name=subject_selector,json=subjectSelector,proto3" json:"subject_selector,omitempty"`
-	ProbeUrl        string   `protobuf:"bytes,3,opt,name=probe_url,json=probeUrl,proto3" json:"probe_url,omitempty"`
-	ProbeInterval   int64    `protobuf:"varint,4,opt,name=probe_interval,json=probeInterval,proto3" json:"probe_interval,omitempty"`
-	unknownFields   protoimpl.UnknownFields
-	sizeCache       protoimpl.SizeCache
+	SubjectSelector       []string `protobuf:"bytes,2,rep,name=subject_selector,json=subjectSelector,proto3" json:"subject_selector,omitempty"`
+	ProbeUrl              string   `protobuf:"bytes,3,opt,name=probe_url,json=probeUrl,proto3" json:"probe_url,omitempty"`
+	ProbeInterval         int64    `protobuf:"varint,4,opt,name=probe_interval,json=probeInterval,proto3" json:"probe_interval,omitempty"`
+	PersistentProbeResult bool     `protobuf:"varint,5,opt,name=persistent_probe_result,json=persistentProbeResult,proto3" json:"persistent_probe_result,omitempty"`
+	unknownFields         protoimpl.UnknownFields
+	sizeCache             protoimpl.SizeCache
 }
 
 func (x *Config) Reset() {
@@ -423,6 +424,13 @@ func (x *Config) GetProbeInterval() int64 {
 	return 0
 }
 
+func (x *Config) GetPersistentProbeResult() bool {
+	if x != nil {
+		return x.PersistentProbeResult
+	}
+	return false
+}
+
 var File_app_observatory_config_proto protoreflect.FileDescriptor
 
 var file_app_observatory_config_proto_rawDesc = string([]byte{
@@ -476,24 +484,28 @@ var file_app_observatory_config_proto_rawDesc = string([]byte{
 	0x22, 0x32, 0x0a, 0x09, 0x49, 0x6e, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x79, 0x12, 0x25, 0x0a,
 	0x0e, 0x70, 0x72, 0x6f, 0x62, 0x65, 0x5f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18,
 	0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0d, 0x70, 0x72, 0x6f, 0x62, 0x65, 0x49, 0x6e, 0x74, 0x65,
-	0x72, 0x76, 0x61, 0x6c, 0x22, 0x9d, 0x01, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12,
+	0x72, 0x76, 0x61, 0x6c, 0x22, 0xd5, 0x01, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12,
 	0x29, 0x0a, 0x10, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x73, 0x65, 0x6c, 0x65, 0x63,
 	0x74, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x75, 0x62, 0x6a, 0x65,
 	0x63, 0x74, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x12, 0x1b, 0x0a, 0x09, 0x70, 0x72,
 	0x6f, 0x62, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70,
 	0x72, 0x6f, 0x62, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x25, 0x0a, 0x0e, 0x70, 0x72, 0x6f, 0x62, 0x65,
 	0x5f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52,
-	0x0d, 0x70, 0x72, 0x6f, 0x62, 0x65, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x3a, 0x24,
-	0x82, 0xb5, 0x18, 0x20, 0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x15, 0x62,
-	0x61, 0x63, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x6e, 0x64, 0x4f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61,
-	0x74, 0x6f, 0x72, 0x79, 0x42, 0x6f, 0x0a, 0x1e, 0x63, 0x6f, 0x6d, 0x2e, 0x76, 0x32, 0x72, 0x61,
-	0x79, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72,
-	0x76, 0x61, 0x74, 0x6f, 0x72, 0x79, 0x50, 0x01, 0x5a, 0x2e, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62,
-	0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x76, 0x32, 0x66, 0x6c, 0x79, 0x2f, 0x76, 0x32, 0x72, 0x61, 0x79,
-	0x2d, 0x63, 0x6f, 0x72, 0x65, 0x2f, 0x76, 0x35, 0x2f, 0x61, 0x70, 0x70, 0x2f, 0x6f, 0x62, 0x73,
-	0x65, 0x72, 0x76, 0x61, 0x74, 0x6f, 0x72, 0x79, 0xaa, 0x02, 0x1a, 0x56, 0x32, 0x52, 0x61, 0x79,
-	0x2e, 0x43, 0x6f, 0x72, 0x65, 0x2e, 0x41, 0x70, 0x70, 0x2e, 0x4f, 0x62, 0x73, 0x65, 0x72, 0x76,
-	0x61, 0x74, 0x6f, 0x72, 0x79, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+	0x0d, 0x70, 0x72, 0x6f, 0x62, 0x65, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x36,
+	0x0a, 0x17, 0x70, 0x65, 0x72, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x74, 0x5f, 0x70, 0x72, 0x6f,
+	0x62, 0x65, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52,
+	0x15, 0x70, 0x65, 0x72, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x74, 0x50, 0x72, 0x6f, 0x62, 0x65,
+	0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x3a, 0x24, 0x82, 0xb5, 0x18, 0x20, 0x0a, 0x07, 0x73, 0x65,
+	0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x15, 0x62, 0x61, 0x63, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x6e,
+	0x64, 0x4f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x6f, 0x72, 0x79, 0x42, 0x6f, 0x0a, 0x1e,
+	0x63, 0x6f, 0x6d, 0x2e, 0x76, 0x32, 0x72, 0x61, 0x79, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x61,
+	0x70, 0x70, 0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x6f, 0x72, 0x79, 0x50, 0x01,
+	0x5a, 0x2e, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x76, 0x32, 0x66,
+	0x6c, 0x79, 0x2f, 0x76, 0x32, 0x72, 0x61, 0x79, 0x2d, 0x63, 0x6f, 0x72, 0x65, 0x2f, 0x76, 0x35,
+	0x2f, 0x61, 0x70, 0x70, 0x2f, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x6f, 0x72, 0x79,
+	0xaa, 0x02, 0x1a, 0x56, 0x32, 0x52, 0x61, 0x79, 0x2e, 0x43, 0x6f, 0x72, 0x65, 0x2e, 0x41, 0x70,
+	0x70, 0x2e, 0x4f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x6f, 0x72, 0x79, 0x62, 0x06, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x33,
 })
 
 var (

+ 2 - 0
app/observatory/config.proto

@@ -84,4 +84,6 @@ message Config {
   string probe_url = 3;
 
   int64 probe_interval = 4;
+
+  bool persistent_probe_result = 5;
 }

+ 54 - 8
app/observatory/observer.go

@@ -12,6 +12,11 @@ import (
 	"sync"
 	"time"
 
+	"github.com/v2fly/v2ray-core/v5/app/persistentstorage"
+	"github.com/v2fly/v2ray-core/v5/app/persistentstorage/protostorage"
+	"github.com/v2fly/v2ray-core/v5/common/environment"
+	"github.com/v2fly/v2ray-core/v5/common/environment/envctx"
+
 	"github.com/golang/protobuf/proto"
 
 	core "github.com/v2fly/v2ray-core/v5"
@@ -34,7 +39,10 @@ type Observer struct {
 
 	finished *done.Instance
 
-	ohm outbound.Manager
+	ohm            outbound.Manager
+	persistStorage persistentstorage.ScopedPersistentStorage
+
+	persistOutboundStatusProtoStorage protostorage.ProtoPersistentStorage
 }
 
 func (o *Observer) GetObservation(ctx context.Context) (proto.Message, error) {
@@ -47,6 +55,24 @@ func (o *Observer) Type() interface{} {
 
 func (o *Observer) Start() error {
 	if o.config != nil && len(o.config.SubjectSelector) != 0 {
+		if o.config.PersistentProbeResult {
+			appEnvironment := envctx.EnvironmentFromContext(o.ctx).(environment.AppEnvironment)
+			o.persistStorage = appEnvironment.PersistentStorage()
+
+			outboundStatusStorage, err := o.persistStorage.NarrowScope(o.ctx, []byte("outbound_status"))
+			if err != nil {
+				return newError("failed to get persistent storage for outbound_status").Base(err)
+			}
+			o.persistOutboundStatusProtoStorage = outboundStatusStorage.(protostorage.ProtoPersistentStorage)
+			list, err := outboundStatusStorage.List(o.ctx, []byte(""))
+			if err != nil {
+				newError("failed to list persisted outbound status").Base(err).WriteToLog()
+			} else {
+				for _, v := range list {
+					o.loadOutboundStatus(string(v))
+				}
+			}
+		}
 		o.finished = done.New()
 		go o.background()
 	}
@@ -195,6 +221,12 @@ func (o *Observer) updateStatusForResult(outbound string, result *ProbeResult) {
 		status.LastErrorReason = result.LastErrorReason
 		status.Delay = 99999999
 	}
+	if o.config.PersistentProbeResult {
+		err := o.persistOutboundStatusProtoStorage.PutProto(o.ctx, outbound, status)
+		if err != nil {
+			newError("failed to persist outbound status").Base(err).WriteToLog()
+		}
+	}
 }
 
 func (o *Observer) findStatusLocationLockHolderOnly(outbound string) int {
@@ -206,19 +238,33 @@ func (o *Observer) findStatusLocationLockHolderOnly(outbound string) int {
 	return -1
 }
 
+func (o *Observer) loadOutboundStatus(name string) {
+	if o.persistOutboundStatusProtoStorage == nil {
+		return
+	}
+	status := &OutboundStatus{}
+	err := o.persistOutboundStatusProtoStorage.GetProto(o.ctx, name, status)
+	if err != nil {
+		newError("failed to load outbound status").Base(err).WriteToLog()
+		return
+	}
+	o.status = append(o.status, status)
+}
+
 func New(ctx context.Context, config *Config) (*Observer, error) {
-	var outboundManager outbound.Manager
+	obs := &Observer{
+		config: config,
+		ctx:    ctx,
+	}
+
 	err := core.RequireFeatures(ctx, func(om outbound.Manager) {
-		outboundManager = om
+		obs.ohm = om
 	})
 	if err != nil {
 		return nil, newError("Cannot get depended features").Base(err)
 	}
-	return &Observer{
-		config: config,
-		ctx:    ctx,
-		ohm:    outboundManager,
-	}, nil
+
+	return obs, nil
 }
 
 func init() {

+ 214 - 0
app/persistentstorage/filesystemstorage/config.pb.go

@@ -0,0 +1,214 @@
+package filesystemstorage
+
+import (
+	_ "github.com/v2fly/v2ray-core/v5/common/protoext"
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	reflect "reflect"
+	sync "sync"
+	unsafe "unsafe"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type StateStorageRoot int32
+
+const (
+	StateStorageRoot_WorkDir StateStorageRoot = 0
+)
+
+// Enum value maps for StateStorageRoot.
+var (
+	StateStorageRoot_name = map[int32]string{
+		0: "WorkDir",
+	}
+	StateStorageRoot_value = map[string]int32{
+		"WorkDir": 0,
+	}
+)
+
+func (x StateStorageRoot) Enum() *StateStorageRoot {
+	p := new(StateStorageRoot)
+	*p = x
+	return p
+}
+
+func (x StateStorageRoot) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (StateStorageRoot) Descriptor() protoreflect.EnumDescriptor {
+	return file_app_persistentstorage_filesystemstorage_config_proto_enumTypes[0].Descriptor()
+}
+
+func (StateStorageRoot) Type() protoreflect.EnumType {
+	return &file_app_persistentstorage_filesystemstorage_config_proto_enumTypes[0]
+}
+
+func (x StateStorageRoot) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use StateStorageRoot.Descriptor instead.
+func (StateStorageRoot) EnumDescriptor() ([]byte, []int) {
+	return file_app_persistentstorage_filesystemstorage_config_proto_rawDescGZIP(), []int{0}
+}
+
+type Config struct {
+	state            protoimpl.MessageState `protogen:"open.v1"`
+	StateStorageRoot StateStorageRoot       `protobuf:"varint,1,opt,name=state_storage_root,json=stateStorageRoot,proto3,enum=v2ray.core.app.persistentstorage.filesystemstorage.StateStorageRoot" json:"state_storage_root,omitempty"`
+	InstanceName     string                 `protobuf:"bytes,4,opt,name=instance_name,json=instanceName,proto3" json:"instance_name,omitempty"`
+	Protojson        bool                   `protobuf:"varint,5,opt,name=protojson,proto3" json:"protojson,omitempty"`
+	unknownFields    protoimpl.UnknownFields
+	sizeCache        protoimpl.SizeCache
+}
+
+func (x *Config) Reset() {
+	*x = Config{}
+	mi := &file_app_persistentstorage_filesystemstorage_config_proto_msgTypes[0]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *Config) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Config) ProtoMessage() {}
+
+func (x *Config) ProtoReflect() protoreflect.Message {
+	mi := &file_app_persistentstorage_filesystemstorage_config_proto_msgTypes[0]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Config.ProtoReflect.Descriptor instead.
+func (*Config) Descriptor() ([]byte, []int) {
+	return file_app_persistentstorage_filesystemstorage_config_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *Config) GetStateStorageRoot() StateStorageRoot {
+	if x != nil {
+		return x.StateStorageRoot
+	}
+	return StateStorageRoot_WorkDir
+}
+
+func (x *Config) GetInstanceName() string {
+	if x != nil {
+		return x.InstanceName
+	}
+	return ""
+}
+
+func (x *Config) GetProtojson() bool {
+	if x != nil {
+		return x.Protojson
+	}
+	return false
+}
+
+var File_app_persistentstorage_filesystemstorage_config_proto protoreflect.FileDescriptor
+
+var file_app_persistentstorage_filesystemstorage_config_proto_rawDesc = string([]byte{
+	0x0a, 0x34, 0x61, 0x70, 0x70, 0x2f, 0x70, 0x65, 0x72, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x74,
+	0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2f, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x79, 0x73, 0x74,
+	0x65, 0x6d, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67,
+	0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x32, 0x76, 0x32, 0x72, 0x61, 0x79, 0x2e, 0x63, 0x6f,
+	0x72, 0x65, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x70, 0x65, 0x72, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e,
+	0x74, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2e, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x79, 0x73,
+	0x74, 0x65, 0x6d, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x63, 0x6f, 0x6d, 0x6d,
+	0x6f, 0x6e, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x65, 0x78, 0x74, 0x2f, 0x65, 0x78, 0x74, 0x65,
+	0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xe1, 0x01, 0x0a,
+	0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x72, 0x0a, 0x12, 0x73, 0x74, 0x61, 0x74, 0x65,
+	0x5f, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x5f, 0x72, 0x6f, 0x6f, 0x74, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x0e, 0x32, 0x44, 0x2e, 0x76, 0x32, 0x72, 0x61, 0x79, 0x2e, 0x63, 0x6f, 0x72, 0x65,
+	0x2e, 0x61, 0x70, 0x70, 0x2e, 0x70, 0x65, 0x72, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x74, 0x73,
+	0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2e, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x79, 0x73, 0x74, 0x65,
+	0x6d, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x65, 0x53, 0x74,
+	0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x6f, 0x6f, 0x74, 0x52, 0x10, 0x73, 0x74, 0x61, 0x74, 0x65,
+	0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x6f, 0x6f, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x69,
+	0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x0c, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65,
+	0x12, 0x1c, 0x0a, 0x09, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x05, 0x20,
+	0x01, 0x28, 0x08, 0x52, 0x09, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x6a, 0x73, 0x6f, 0x6e, 0x3a, 0x20,
+	0x82, 0xb5, 0x18, 0x1c, 0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x11, 0x66,
+	0x69, 0x6c, 0x65, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65,
+	0x2a, 0x1f, 0x0a, 0x10, 0x53, 0x74, 0x61, 0x74, 0x65, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65,
+	0x52, 0x6f, 0x6f, 0x74, 0x12, 0x0b, 0x0a, 0x07, 0x57, 0x6f, 0x72, 0x6b, 0x44, 0x69, 0x72, 0x10,
+	0x00, 0x42, 0xb3, 0x01, 0x0a, 0x32, 0x63, 0x6f, 0x6d, 0x2e, 0x76, 0x32, 0x72, 0x61, 0x79, 0x2e,
+	0x63, 0x6f, 0x72, 0x65, 0x2e, 0x70, 0x65, 0x72, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x74, 0x73,
+	0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2e, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x79, 0x73, 0x74, 0x65,
+	0x6d, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x50, 0x01, 0x5a, 0x46, 0x67, 0x69, 0x74, 0x68,
+	0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x76, 0x32, 0x66, 0x6c, 0x79, 0x2f, 0x76, 0x32, 0x72,
+	0x61, 0x79, 0x2d, 0x63, 0x6f, 0x72, 0x65, 0x2f, 0x76, 0x35, 0x2f, 0x61, 0x70, 0x70, 0x2f, 0x70,
+	0x65, 0x72, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65,
+	0x2f, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x73, 0x74, 0x6f, 0x72, 0x61,
+	0x67, 0x65, 0xaa, 0x02, 0x32, 0x56, 0x32, 0x52, 0x61, 0x79, 0x2e, 0x43, 0x6f, 0x72, 0x65, 0x2e,
+	0x41, 0x70, 0x70, 0x2e, 0x50, 0x65, 0x72, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x74,
+	0x6f, 0x72, 0x61, 0x67, 0x65, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d,
+	0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+})
+
+var (
+	file_app_persistentstorage_filesystemstorage_config_proto_rawDescOnce sync.Once
+	file_app_persistentstorage_filesystemstorage_config_proto_rawDescData []byte
+)
+
+func file_app_persistentstorage_filesystemstorage_config_proto_rawDescGZIP() []byte {
+	file_app_persistentstorage_filesystemstorage_config_proto_rawDescOnce.Do(func() {
+		file_app_persistentstorage_filesystemstorage_config_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_app_persistentstorage_filesystemstorage_config_proto_rawDesc), len(file_app_persistentstorage_filesystemstorage_config_proto_rawDesc)))
+	})
+	return file_app_persistentstorage_filesystemstorage_config_proto_rawDescData
+}
+
+var file_app_persistentstorage_filesystemstorage_config_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
+var file_app_persistentstorage_filesystemstorage_config_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
+var file_app_persistentstorage_filesystemstorage_config_proto_goTypes = []any{
+	(StateStorageRoot)(0), // 0: v2ray.core.app.persistentstorage.filesystemstorage.StateStorageRoot
+	(*Config)(nil),        // 1: v2ray.core.app.persistentstorage.filesystemstorage.Config
+}
+var file_app_persistentstorage_filesystemstorage_config_proto_depIdxs = []int32{
+	0, // 0: v2ray.core.app.persistentstorage.filesystemstorage.Config.state_storage_root:type_name -> v2ray.core.app.persistentstorage.filesystemstorage.StateStorageRoot
+	1, // [1:1] is the sub-list for method output_type
+	1, // [1:1] is the sub-list for method input_type
+	1, // [1:1] is the sub-list for extension type_name
+	1, // [1:1] is the sub-list for extension extendee
+	0, // [0:1] is the sub-list for field type_name
+}
+
+func init() { file_app_persistentstorage_filesystemstorage_config_proto_init() }
+func file_app_persistentstorage_filesystemstorage_config_proto_init() {
+	if File_app_persistentstorage_filesystemstorage_config_proto != nil {
+		return
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: unsafe.Slice(unsafe.StringData(file_app_persistentstorage_filesystemstorage_config_proto_rawDesc), len(file_app_persistentstorage_filesystemstorage_config_proto_rawDesc)),
+			NumEnums:      1,
+			NumMessages:   1,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_app_persistentstorage_filesystemstorage_config_proto_goTypes,
+		DependencyIndexes: file_app_persistentstorage_filesystemstorage_config_proto_depIdxs,
+		EnumInfos:         file_app_persistentstorage_filesystemstorage_config_proto_enumTypes,
+		MessageInfos:      file_app_persistentstorage_filesystemstorage_config_proto_msgTypes,
+	}.Build()
+	File_app_persistentstorage_filesystemstorage_config_proto = out.File
+	file_app_persistentstorage_filesystemstorage_config_proto_goTypes = nil
+	file_app_persistentstorage_filesystemstorage_config_proto_depIdxs = nil
+}

+ 23 - 0
app/persistentstorage/filesystemstorage/config.proto

@@ -0,0 +1,23 @@
+syntax = "proto3";
+
+package v2ray.core.app.persistentstorage.filesystemstorage;
+option csharp_namespace = "V2Ray.Core.App.Persistentstorage.Filesystemstorage";
+option go_package = "github.com/v2fly/v2ray-core/v5/app/persistentstorage/filesystemstorage";
+option java_package = "com.v2ray.core.persistentstorage.filesystemstorage";
+option java_multiple_files = true;
+
+import "common/protoext/extensions.proto";
+
+enum StateStorageRoot {
+  WorkDir = 0;
+}
+
+message Config {
+  option (v2ray.core.common.protoext.message_opt).type = "service";
+  option (v2ray.core.common.protoext.message_opt).short_name = "filesystemstorage";
+
+  StateStorageRoot state_storage_root = 1;
+  string instance_name = 4;
+
+  bool protojson = 5;
+}

+ 133 - 0
app/persistentstorage/filesystemstorage/fs.go

@@ -0,0 +1,133 @@
+package filesystemstorage
+
+import (
+	"bytes"
+	"context"
+	"io"
+	"path/filepath"
+	"strings"
+
+	"google.golang.org/protobuf/proto"
+
+	"github.com/v2fly/v2ray-core/v5/app/persistentstorage/protostorage"
+	"github.com/v2fly/v2ray-core/v5/common"
+	"github.com/v2fly/v2ray-core/v5/common/environment"
+	"github.com/v2fly/v2ray-core/v5/common/environment/envctx"
+	"github.com/v2fly/v2ray-core/v5/features/extension/storage"
+)
+
+func newFileSystemStorage(ctx context.Context, config *Config) storage.ScopedPersistentStorageService {
+	appEnvironment := envctx.EnvironmentFromContext(ctx).(environment.AppEnvironment)
+	fss := &fileSystemStorage{
+		fs:              appEnvironment,
+		pathRoot:        config.InstanceName,
+		currentLocation: nil,
+		config:          config,
+	}
+
+	protoStorageInst := protostorage.NewProtoStorage(fss, config.Protojson)
+	fss.proto = protoStorageInst
+	return fss
+}
+
+type fileSystemStorage struct {
+	fs    environment.FileSystemCapabilitySet
+	proto protostorage.ProtoPersistentStorage
+
+	pathRoot        string
+	currentLocation []string
+	config          *Config
+}
+
+func (f *fileSystemStorage) Type() interface{} {
+	return storage.ScopedPersistentStorageServiceType
+}
+
+func (f *fileSystemStorage) Start() error {
+	return nil
+}
+
+func (f *fileSystemStorage) Close() error {
+	return nil
+}
+
+func (f *fileSystemStorage) PutProto(ctx context.Context, key string, pb proto.Message) error {
+	return f.proto.PutProto(ctx, key, pb)
+}
+
+func (f *fileSystemStorage) GetProto(ctx context.Context, key string, pb proto.Message) error {
+	return f.proto.GetProto(ctx, key, pb)
+}
+
+func (f *fileSystemStorage) ScopedPersistentStorageEngine() {
+}
+
+func (f *fileSystemStorage) Put(ctx context.Context, key []byte, value []byte) error {
+	finalPath := filepath.Join(f.pathRoot, filepath.Join(f.currentLocation...), string(key))
+	if value == nil {
+		return f.fs.RemoveFile()(finalPath)
+	}
+	writer, err := f.fs.OpenFileForWrite()(finalPath)
+	if err != nil {
+		return err
+	}
+	defer writer.Close()
+	_, err = io.Copy(writer, io.NopCloser(bytes.NewReader(value)))
+	return err
+}
+
+func (f *fileSystemStorage) Get(ctx context.Context, key []byte) ([]byte, error) {
+	finalPath := filepath.Join(f.pathRoot, filepath.Join(f.currentLocation...), string(key))
+	reader, err := f.fs.OpenFileForRead()(finalPath)
+	if err != nil {
+		return nil, err
+	}
+	defer reader.Close()
+	return io.ReadAll(reader)
+}
+
+func (f *fileSystemStorage) List(ctx context.Context, keyPrefix []byte) ([][]byte, error) {
+	res, err := f.fs.ReadDir()(filepath.Join(f.pathRoot, filepath.Join(f.currentLocation...)))
+	if err != nil {
+		return nil, err
+	}
+	var result [][]byte
+	for _, entry := range res {
+		if !entry.IsDir() && bytes.HasPrefix([]byte(entry.Name()), keyPrefix) {
+			result = append(result, []byte(entry.Name()))
+		}
+	}
+	return result, nil
+}
+
+func (f *fileSystemStorage) Clear(ctx context.Context) {
+	allFile, err := f.List(ctx, []byte{})
+	if err != nil {
+		return
+	}
+	for _, file := range allFile {
+		_ = f.Put(ctx, file, nil)
+	}
+}
+
+func (f *fileSystemStorage) NarrowScope(ctx context.Context, key []byte) (storage.ScopedPersistentStorage, error) {
+	escapedKey := strings.ReplaceAll(string(key), "/", "_")
+	fss := &fileSystemStorage{
+		fs:              f.fs,
+		pathRoot:        f.pathRoot,
+		currentLocation: append(f.currentLocation, escapedKey),
+		config:          f.config,
+	}
+	fss.proto = protostorage.NewProtoStorage(fss, f.config.Protojson)
+	return fss, nil
+}
+
+func (f *fileSystemStorage) DropScope(ctx context.Context, key []byte) error {
+	panic("unimplemented")
+}
+
+func init() {
+	common.Must(common.RegisterConfig((*Config)(nil), func(ctx context.Context, config interface{}) (interface{}, error) {
+		return newFileSystemStorage(ctx, config.(*Config)), nil
+	}))
+}

+ 51 - 0
app/persistentstorage/protostorage/protokv.go

@@ -0,0 +1,51 @@
+package protostorage
+
+import (
+	"context"
+
+	"google.golang.org/protobuf/encoding/protojson"
+	"google.golang.org/protobuf/proto"
+
+	"github.com/v2fly/v2ray-core/v5/features/extension/storage"
+)
+
+type ProtoPersistentStorage interface {
+	PutProto(ctx context.Context, key string, pb proto.Message) error
+	GetProto(ctx context.Context, key string, pb proto.Message) error
+}
+
+type protoStorage struct {
+	storage    storage.ScopedPersistentStorage
+	textFormat bool
+}
+
+func (p *protoStorage) PutProto(ctx context.Context, key string, pb proto.Message) error {
+	if !p.textFormat {
+		data, err := proto.Marshal(pb)
+		if err != nil {
+			return err
+		}
+		return p.storage.Put(ctx, []byte(key), data)
+	} else {
+		protojsonStr := protojson.Format(pb)
+		return p.storage.Put(ctx, []byte(key), []byte(protojsonStr))
+	}
+}
+
+func (p *protoStorage) GetProto(ctx context.Context, key string, pb proto.Message) error {
+	data, err := p.storage.Get(ctx, []byte(key))
+	if err != nil {
+		return err
+	}
+	if !p.textFormat {
+		return proto.Unmarshal(data, pb)
+	}
+	return protojson.Unmarshal(data, pb)
+}
+
+func NewProtoStorage(storage storage.ScopedPersistentStorage, textFormat bool) ProtoPersistentStorage {
+	return &protoStorage{
+		storage:    storage,
+		textFormat: textFormat,
+	}
+}

+ 5 - 0
app/persistentstorage/storage.go

@@ -0,0 +1,5 @@
+package persistentstorage
+
+import "github.com/v2fly/v2ray-core/v5/features/extension/storage"
+
+type ScopedPersistentStorage = storage.ScopedPersistentStorage

+ 16 - 8
app/proxyman/outbound/handler_test.go

@@ -5,18 +5,18 @@ import (
 	"testing"
 	_ "unsafe"
 
-	"github.com/v2fly/v2ray-core/v5/common/environment/systemnetworkimpl"
-
-	"github.com/v2fly/v2ray-core/v5/common/environment"
-	"github.com/v2fly/v2ray-core/v5/common/environment/envctx"
-	"github.com/v2fly/v2ray-core/v5/common/environment/transientstorageimpl"
-
 	"google.golang.org/protobuf/types/known/anypb"
 
 	core "github.com/v2fly/v2ray-core/v5"
 	"github.com/v2fly/v2ray-core/v5/app/policy"
 	. "github.com/v2fly/v2ray-core/v5/app/proxyman/outbound"
 	"github.com/v2fly/v2ray-core/v5/app/stats"
+	"github.com/v2fly/v2ray-core/v5/common/environment"
+	"github.com/v2fly/v2ray-core/v5/common/environment/deferredpersistentstorage"
+	"github.com/v2fly/v2ray-core/v5/common/environment/envctx"
+	"github.com/v2fly/v2ray-core/v5/common/environment/filesystemimpl"
+	"github.com/v2fly/v2ray-core/v5/common/environment/systemnetworkimpl"
+	"github.com/v2fly/v2ray-core/v5/common/environment/transientstorageimpl"
 	"github.com/v2fly/v2ray-core/v5/common/net"
 	"github.com/v2fly/v2ray-core/v5/common/serial"
 	"github.com/v2fly/v2ray-core/v5/features/outbound"
@@ -50,7 +50,11 @@ func TestOutboundWithoutStatCounter(t *testing.T) {
 	v.AddFeature((outbound.Manager)(new(Manager)))
 	ctx := toContext(context.Background(), v)
 	defaultNetworkImpl := systemnetworkimpl.NewSystemNetworkDefault()
-	rootEnv := environment.NewRootEnvImpl(ctx, transientstorageimpl.NewScopedTransientStorageImpl(), defaultNetworkImpl.Dialer(), defaultNetworkImpl.Listener())
+	defaultFilesystemImpl := filesystemimpl.NewDefaultFileSystemDefaultImpl()
+	deferredPersistentStorageImpl := deferredpersistentstorage.NewDeferredPersistentStorage(ctx)
+	rootEnv := environment.NewRootEnvImpl(ctx,
+		transientstorageimpl.NewScopedTransientStorageImpl(), defaultNetworkImpl.Dialer(), defaultNetworkImpl.Listener(),
+		defaultFilesystemImpl, deferredPersistentStorageImpl)
 	proxyEnvironment := rootEnv.ProxyEnvironment("o")
 	ctx = envctx.ContextWithEnvironment(ctx, proxyEnvironment)
 	h, _ := NewHandler(ctx, &core.OutboundHandlerConfig{
@@ -83,7 +87,11 @@ func TestOutboundWithStatCounter(t *testing.T) {
 	v.AddFeature((outbound.Manager)(new(Manager)))
 	ctx := toContext(context.Background(), v)
 	defaultNetworkImpl := systemnetworkimpl.NewSystemNetworkDefault()
-	rootEnv := environment.NewRootEnvImpl(ctx, transientstorageimpl.NewScopedTransientStorageImpl(), defaultNetworkImpl.Dialer(), defaultNetworkImpl.Listener())
+	defaultFilesystemImpl := filesystemimpl.NewDefaultFileSystemDefaultImpl()
+	deferredPersistentStorageImpl := deferredpersistentstorage.NewDeferredPersistentStorage(ctx)
+	rootEnv := environment.NewRootEnvImpl(ctx,
+		transientstorageimpl.NewScopedTransientStorageImpl(), defaultNetworkImpl.Dialer(), defaultNetworkImpl.Listener(),
+		defaultFilesystemImpl, deferredPersistentStorageImpl)
 	proxyEnvironment := rootEnv.ProxyEnvironment("o")
 	ctx = envctx.ContextWithEnvironment(ctx, proxyEnvironment)
 	h, _ := NewHandler(ctx, &core.OutboundHandlerConfig{

+ 111 - 0
common/environment/deferredpersistentstorage/defereredPersistentStorage.go

@@ -0,0 +1,111 @@
+package deferredpersistentstorage
+
+import (
+	"context"
+
+	"github.com/v2fly/v2ray-core/v5/app/persistentstorage"
+	"github.com/v2fly/v2ray-core/v5/common/errors"
+	"github.com/v2fly/v2ray-core/v5/features/extension/storage"
+)
+
+type DeferredPersistentStorage interface {
+	storage.ScopedPersistentStorage
+	ProvideInner(ctx context.Context, inner persistentstorage.ScopedPersistentStorage)
+}
+
+var errNotExist = errors.New("persistent storage does not exist")
+
+type deferredPersistentStorage struct {
+	ready context.Context
+	done  context.CancelFunc
+	inner persistentstorage.ScopedPersistentStorage
+
+	awaitingChildren []*deferredPersistentStorage
+
+	intoScopes []string
+}
+
+func (d *deferredPersistentStorage) ScopedPersistentStorageEngine() {
+}
+
+func (d *deferredPersistentStorage) Put(ctx context.Context, key []byte, value []byte) error {
+	<-d.ready.Done()
+	if d.inner == nil {
+		return errNotExist
+	}
+	return d.inner.Put(ctx, key, value)
+}
+
+func (d *deferredPersistentStorage) Get(ctx context.Context, key []byte) ([]byte, error) {
+	<-d.ready.Done()
+	if d.inner == nil {
+		return nil, errNotExist
+	}
+	return d.inner.Get(ctx, key)
+}
+
+func (d *deferredPersistentStorage) List(ctx context.Context, keyPrefix []byte) ([][]byte, error) {
+	<-d.ready.Done()
+	if d.inner == nil {
+		return nil, errNotExist
+	}
+	return d.inner.List(ctx, keyPrefix)
+}
+
+func (d *deferredPersistentStorage) Clear(ctx context.Context) {
+	<-d.ready.Done()
+	if d.inner == nil {
+		return
+	}
+	d.inner.Clear(ctx)
+}
+
+func (d *deferredPersistentStorage) NarrowScope(ctx context.Context, key []byte) (storage.ScopedPersistentStorage, error) {
+	if d.ready.Err() != nil {
+		return d.inner.NarrowScope(ctx, key)
+	}
+	ready, done := context.WithCancel(ctx)
+	swallowCopyScopes := d.intoScopes
+	dps := &deferredPersistentStorage{
+		ready:      ready,
+		done:       done,
+		inner:      nil,
+		intoScopes: append(swallowCopyScopes, string(key)),
+	}
+	d.awaitingChildren = append(d.awaitingChildren, dps)
+	return dps, nil
+}
+
+func (d *deferredPersistentStorage) DropScope(ctx context.Context, key []byte) error {
+	<-d.ready.Done()
+	if d.inner == nil {
+		return errNotExist
+	}
+	return d.inner.DropScope(ctx, key)
+}
+
+func (d *deferredPersistentStorage) ProvideInner(ctx context.Context, inner persistentstorage.ScopedPersistentStorage) {
+	d.inner = inner
+	if inner != nil {
+		for _, scope := range d.intoScopes {
+			newScope, err := inner.NarrowScope(ctx, []byte(scope))
+			if err != nil {
+				panic(err)
+			}
+			d.inner = newScope
+		}
+	}
+	for _, child := range d.awaitingChildren {
+		child.ProvideInner(ctx, d.inner)
+	}
+	d.done()
+}
+
+func NewDeferredPersistentStorage(ctx context.Context) DeferredPersistentStorage {
+	ready, done := context.WithCancel(ctx)
+	return &deferredPersistentStorage{
+		ready: ready,
+		done:  done,
+		inner: nil,
+	}
+}

+ 8 - 0
common/environment/envimpl/fs.go

@@ -8,6 +8,14 @@ import (
 
 type fileSystemDefaultImpl struct{}
 
+func (f fileSystemDefaultImpl) ReadDir() fsifce.FileReadDirFunc {
+	return filesystem.NewFileReadDir
+}
+
+func (f fileSystemDefaultImpl) RemoveFile() fsifce.FileRemoveFunc {
+	return filesystem.NewFileRemover
+}
+
 func (f fileSystemDefaultImpl) OpenFileForReadSeek() fsifce.FileSeekerFunc {
 	return filesystem.NewFileSeeker
 }

+ 2 - 0
common/environment/filesystemcap/fscap.go

@@ -6,4 +6,6 @@ type FileSystemCapabilitySet interface {
 	OpenFileForReadSeek() fsifce.FileSeekerFunc
 	OpenFileForRead() fsifce.FileReaderFunc
 	OpenFileForWrite() fsifce.FileWriterFunc
+	ReadDir() fsifce.FileReadDirFunc
+	RemoveFile() fsifce.FileRemoveFunc
 }

+ 33 - 0
common/environment/filesystemimpl/fsimpl.go

@@ -0,0 +1,33 @@
+package filesystemimpl
+
+import (
+	"github.com/v2fly/v2ray-core/v5/common/environment"
+	"github.com/v2fly/v2ray-core/v5/common/platform/filesystem"
+	"github.com/v2fly/v2ray-core/v5/common/platform/filesystem/fsifce"
+)
+
+func NewDefaultFileSystemDefaultImpl() environment.FileSystemCapabilitySet {
+	return fsCapImpl{}
+}
+
+type fsCapImpl struct{}
+
+func (f fsCapImpl) OpenFileForReadSeek() fsifce.FileSeekerFunc {
+	return filesystem.NewFileSeeker
+}
+
+func (f fsCapImpl) OpenFileForRead() fsifce.FileReaderFunc {
+	return filesystem.NewFileReader
+}
+
+func (f fsCapImpl) OpenFileForWrite() fsifce.FileWriterFunc {
+	return filesystem.NewFileWriter
+}
+
+func (f fsCapImpl) ReadDir() fsifce.FileReadDirFunc {
+	return filesystem.NewFileReadDir
+}
+
+func (f fsCapImpl) RemoveFile() fsifce.FileRemoveFunc {
+	return filesystem.NewFileRemover
+}

+ 25 - 4
common/environment/rootcap_impl.go

@@ -11,19 +11,24 @@ import (
 
 func NewRootEnvImpl(ctx context.Context, transientStorage storage.ScopedTransientStorage,
 	systemDialer internet.SystemDialer, systemListener internet.SystemListener,
+	filesystem FileSystemCapabilitySet, persistStorage storage.ScopedPersistentStorage,
 ) RootEnvironment {
 	return &rootEnvImpl{
 		transientStorage: transientStorage,
 		systemListener:   systemListener,
 		systemDialer:     systemDialer,
+		filesystem:       filesystem,
+		persistStorage:   persistStorage,
 		ctx:              ctx,
 	}
 }
 
 type rootEnvImpl struct {
+	persistStorage   storage.ScopedPersistentStorage
 	transientStorage storage.ScopedTransientStorage
 	systemDialer     internet.SystemDialer
 	systemListener   internet.SystemListener
+	filesystem       FileSystemCapabilitySet
 
 	ctx context.Context
 }
@@ -37,10 +42,16 @@ func (r *rootEnvImpl) AppEnvironment(tag string) AppEnvironment {
 	if err != nil {
 		return nil
 	}
+	persistStorage, err := r.persistStorage.NarrowScope(r.ctx, []byte(tag))
+	if err != nil {
+		return nil
+	}
 	return &appEnvImpl{
 		transientStorage: transientStorage,
+		persistStorage:   persistStorage,
 		systemListener:   r.systemListener,
 		systemDialer:     r.systemDialer,
+		filesystem:       r.filesystem,
 		ctx:              r.ctx,
 	}
 }
@@ -67,9 +78,11 @@ func (r *rootEnvImpl) DropProxyEnvironment(tag string) error {
 }
 
 type appEnvImpl struct {
+	persistStorage   storage.ScopedPersistentStorage
 	transientStorage storage.ScopedTransientStorage
 	systemDialer     internet.SystemDialer
 	systemListener   internet.SystemListener
+	filesystem       FileSystemCapabilitySet
 
 	ctx context.Context
 }
@@ -95,19 +108,27 @@ func (a *appEnvImpl) OutboundDialer() tagged.DialFunc {
 }
 
 func (a *appEnvImpl) OpenFileForReadSeek() fsifce.FileSeekerFunc {
-	panic("implement me")
+	return a.filesystem.OpenFileForReadSeek()
 }
 
 func (a *appEnvImpl) OpenFileForRead() fsifce.FileReaderFunc {
-	panic("implement me")
+	return a.filesystem.OpenFileForRead()
 }
 
 func (a *appEnvImpl) OpenFileForWrite() fsifce.FileWriterFunc {
-	panic("implement me")
+	return a.filesystem.OpenFileForWrite()
+}
+
+func (a *appEnvImpl) ReadDir() fsifce.FileReadDirFunc {
+	return a.filesystem.ReadDir()
+}
+
+func (a *appEnvImpl) RemoveFile() fsifce.FileRemoveFunc {
+	return a.filesystem.RemoveFile()
 }
 
 func (a *appEnvImpl) PersistentStorage() storage.ScopedPersistentStorage {
-	panic("implement me")
+	return a.persistStorage
 }
 
 func (a *appEnvImpl) TransientStorage() storage.ScopedTransientStorage {

+ 9 - 0
common/platform/filesystem/file.go

@@ -3,6 +3,7 @@ package filesystem
 import (
 	"io"
 	"os"
+	"path/filepath"
 
 	"github.com/v2fly/v2ray-core/v5/common/buf"
 	"github.com/v2fly/v2ray-core/v5/common/platform"
@@ -18,9 +19,17 @@ var NewFileReader fsifce.FileReaderFunc = func(path string) (io.ReadCloser, erro
 }
 
 var NewFileWriter fsifce.FileWriterFunc = func(path string) (io.WriteCloser, error) {
+	basePath := filepath.Dir(path)
+	if err := os.MkdirAll(basePath, 0o700); err != nil {
+		return nil, err
+	}
 	return os.Create(path)
 }
 
+var NewFileRemover fsifce.FileRemoveFunc = os.Remove
+
+var NewFileReadDir fsifce.FileReadDirFunc = os.ReadDir
+
 func ReadFile(path string) ([]byte, error) {
 	reader, err := NewFileReader(path)
 	if err != nil {

+ 8 - 1
common/platform/filesystem/fsifce/ifce.go

@@ -1,9 +1,16 @@
 package fsifce
 
-import "io"
+import (
+	"io"
+	"io/fs"
+)
 
 type FileSeekerFunc func(path string) (io.ReadSeekCloser, error)
 
 type FileReaderFunc func(path string) (io.ReadCloser, error)
 
 type FileWriterFunc func(path string) (io.WriteCloser, error)
+
+type FileReadDirFunc func(path string) ([]fs.DirEntry, error)
+
+type FileRemoveFunc func(path string) error

+ 9 - 0
features/extension/storage/storage.go

@@ -2,6 +2,8 @@ package storage
 
 import (
 	"context"
+
+	"github.com/v2fly/v2ray-core/v5/features"
 )
 
 type ScopedPersistentStorage interface {
@@ -23,3 +25,10 @@ type ScopedTransientStorage interface {
 	NarrowScope(ctx context.Context, key string) (ScopedTransientStorage, error)
 	DropScope(ctx context.Context, key string) error
 }
+
+type ScopedPersistentStorageService interface {
+	ScopedPersistentStorage
+	features.Feature
+}
+
+var ScopedPersistentStorageServiceType = (*ScopedPersistentStorageService)(nil)

+ 2 - 9
infra/vprotogen/main.go

@@ -108,9 +108,9 @@ func getProjectProtocVersion(url string) (string, error) {
 	if err != nil {
 		return "", fmt.Errorf("can not read from body")
 	}
-	versionRegexp := regexp.MustCompile(`\/\/\s*protoc\s*v(\d+\.\d+\.\d+)`)
+	versionRegexp := regexp.MustCompile(`\/\/\s*protoc\s*v(\d+\.(\d+\.\d+))`)
 	matched := versionRegexp.FindStringSubmatch(string(body))
-	return matched[1], nil
+	return matched[2], nil
 }
 
 func getInstalledProtocVersion(protocPath string) (string, error) {
@@ -126,10 +126,6 @@ func getInstalledProtocVersion(protocPath string) (string, error) {
 	if len(matched) == 0 {
 		return "", errors.New("can not parse protoc version")
 	}
-
-	if len(matched) == 2 {
-		installedVersion += "4." // in contrast to getProjectProtocVersion()
-	}
 	installedVersion += matched[1]
 	fmt.Println("Using protoc version: " + installedVersion)
 	return installedVersion, nil
@@ -139,9 +135,6 @@ func parseVersion(s string, width int) int64 {
 	strList := strings.Split(s, ".")
 	format := fmt.Sprintf("%%s%%0%ds", width)
 	v := ""
-	if len(strList) == 2 {
-		strList = append([]string{"4"}, strList...)
-	}
 	for _, value := range strList {
 		v = fmt.Sprintf(format, v, value)
 	}

+ 1 - 0
main/distro/all/all.go

@@ -34,6 +34,7 @@ import (
 	_ "github.com/v2fly/v2ray-core/v5/app/commander/webcommander"
 	_ "github.com/v2fly/v2ray-core/v5/app/instman"
 	_ "github.com/v2fly/v2ray-core/v5/app/observatory"
+	_ "github.com/v2fly/v2ray-core/v5/app/persistentstorage/filesystemstorage"
 	_ "github.com/v2fly/v2ray-core/v5/app/tun"
 
 	// Inbound and outbound proxies.

+ 8 - 1
transport/internet/kcp/kcp_test.go

@@ -7,6 +7,9 @@ import (
 	"testing"
 	"time"
 
+	"github.com/v2fly/v2ray-core/v5/common/environment/deferredpersistentstorage"
+	"github.com/v2fly/v2ray-core/v5/common/environment/filesystemimpl"
+
 	"github.com/v2fly/v2ray-core/v5/common/environment"
 	"github.com/v2fly/v2ray-core/v5/common/environment/envctx"
 	"github.com/v2fly/v2ray-core/v5/common/environment/systemnetworkimpl"
@@ -25,7 +28,11 @@ import (
 func TestDialAndListen(t *testing.T) {
 	ctx := context.Background()
 	defaultNetworkImpl := systemnetworkimpl.NewSystemNetworkDefault()
-	rootEnv := environment.NewRootEnvImpl(ctx, transientstorageimpl.NewScopedTransientStorageImpl(), defaultNetworkImpl.Dialer(), defaultNetworkImpl.Listener())
+	defaultFilesystemImpl := filesystemimpl.NewDefaultFileSystemDefaultImpl()
+	deferredPersistentStorageImpl := deferredpersistentstorage.NewDeferredPersistentStorage(ctx)
+	rootEnv := environment.NewRootEnvImpl(ctx,
+		transientstorageimpl.NewScopedTransientStorageImpl(), defaultNetworkImpl.Dialer(), defaultNetworkImpl.Listener(),
+		defaultFilesystemImpl, deferredPersistentStorageImpl)
 	proxyEnvironment := rootEnv.ProxyEnvironment("o")
 	transportEnvironment, err := proxyEnvironment.NarrowScopeToTransport("kcp")
 	if err != nil {

+ 1 - 0
transport/internet/tls/ech.go

@@ -13,6 +13,7 @@ import (
 	"time"
 
 	"github.com/miekg/dns"
+
 	"github.com/v2fly/v2ray-core/v5/common/net"
 	"github.com/v2fly/v2ray-core/v5/transport/internet"
 )

+ 18 - 1
v2ray.go

@@ -5,6 +5,10 @@ import (
 	"reflect"
 	sync "sync"
 
+	"github.com/v2fly/v2ray-core/v5/common/environment/deferredpersistentstorage"
+	"github.com/v2fly/v2ray-core/v5/common/environment/filesystemimpl"
+	"github.com/v2fly/v2ray-core/v5/features/extension/storage"
+
 	"github.com/v2fly/v2ray-core/v5/common"
 	"github.com/v2fly/v2ray-core/v5/common/environment"
 	"github.com/v2fly/v2ray-core/v5/common/environment/systemnetworkimpl"
@@ -205,7 +209,14 @@ func initInstanceWithConfig(config *Config, server *Instance) (bool, error) {
 	}
 
 	defaultNetworkImpl := systemnetworkimpl.NewSystemNetworkDefault()
-	server.env = environment.NewRootEnvImpl(server.ctx, transientstorageimpl.NewScopedTransientStorageImpl(), defaultNetworkImpl.Dialer(), defaultNetworkImpl.Listener())
+	defaultFilesystemImpl := filesystemimpl.NewDefaultFileSystemDefaultImpl()
+	deferredPersistentStorageImpl := deferredpersistentstorage.NewDeferredPersistentStorage(server.ctx)
+	server.env = environment.NewRootEnvImpl(server.ctx,
+		transientstorageimpl.NewScopedTransientStorageImpl(),
+		defaultNetworkImpl.Dialer(),
+		defaultNetworkImpl.Listener(),
+		defaultFilesystemImpl,
+		deferredPersistentStorageImpl)
 
 	for _, appSettings := range config.App {
 		settings, err := serial.GetInstanceOf(appSettings)
@@ -247,6 +258,12 @@ func initInstanceWithConfig(config *Config, server *Instance) (bool, error) {
 		return true, newError("not all dependency are resolved.")
 	}
 
+	if persistentStorageService := server.GetFeature(storage.ScopedPersistentStorageServiceType); persistentStorageService != nil {
+		deferredPersistentStorageImpl.ProvideInner(server.ctx, persistentStorageService.(storage.ScopedPersistentStorage))
+	} else {
+		deferredPersistentStorageImpl.ProvideInner(server.ctx, nil)
+	}
+
 	if err := addInboundHandlers(server, config.Inbound); err != nil {
 		return true, err
 	}