diff --git a/api/pb/caddy_storage.pb.go b/api/pb/caddy_storage.pb.go index 34ddb200..8c0c2007 100644 --- a/api/pb/caddy_storage.pb.go +++ b/api/pb/caddy_storage.pb.go @@ -446,6 +446,120 @@ func (x *StatCaddyStorageResponse) GetIsTerminal() bool { return false } +type ListCertificatesResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Certificates []*IssuedCertificate `protobuf:"bytes,1,rep,name=certificates,proto3" json:"certificates,omitempty"` +} + +func (x *ListCertificatesResponse) Reset() { + *x = ListCertificatesResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_api_pb_caddy_storage_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListCertificatesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListCertificatesResponse) ProtoMessage() {} + +func (x *ListCertificatesResponse) ProtoReflect() protoreflect.Message { + mi := &file_api_pb_caddy_storage_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListCertificatesResponse.ProtoReflect.Descriptor instead. +func (*ListCertificatesResponse) Descriptor() ([]byte, []int) { + return file_api_pb_caddy_storage_proto_rawDescGZIP(), []int{8} +} + +func (x *ListCertificatesResponse) GetCertificates() []*IssuedCertificate { + if x != nil { + return x.Certificates + } + return nil +} + +type IssuedCertificate struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Subject Alternative Name (SAN) of the certificate. Caddy doesn't issue certificates with multiple SANs. + San string `protobuf:"bytes,1,opt,name=san,proto3" json:"san,omitempty"` + // The PEM-encoding of DER-encoded ASN.1 data for the cert or chain, leaf first. + Chain []byte `protobuf:"bytes,2,opt,name=chain,proto3" json:"chain,omitempty"` + // Any extra information associated with the certificate, usually provided by the issuer implementation. + // JSON-encoded, may be absent or null. + IssuerData []byte `protobuf:"bytes,3,opt,name=issuer_data,json=issuerData,proto3" json:"issuer_data,omitempty"` +} + +func (x *IssuedCertificate) Reset() { + *x = IssuedCertificate{} + if protoimpl.UnsafeEnabled { + mi := &file_api_pb_caddy_storage_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *IssuedCertificate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IssuedCertificate) ProtoMessage() {} + +func (x *IssuedCertificate) ProtoReflect() protoreflect.Message { + mi := &file_api_pb_caddy_storage_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IssuedCertificate.ProtoReflect.Descriptor instead. +func (*IssuedCertificate) Descriptor() ([]byte, []int) { + return file_api_pb_caddy_storage_proto_rawDescGZIP(), []int{9} +} + +func (x *IssuedCertificate) GetSan() string { + if x != nil { + return x.San + } + return "" +} + +func (x *IssuedCertificate) GetChain() []byte { + if x != nil { + return x.Chain + } + return nil +} + +func (x *IssuedCertificate) GetIssuerData() []byte { + if x != nil { + return x.IssuerData + } + return nil +} + var File_api_pb_caddy_storage_proto protoreflect.FileDescriptor var file_api_pb_caddy_storage_proto_rawDesc = []byte{ @@ -492,32 +606,48 @@ var file_api_pb_caddy_storage_proto_rawDesc = []byte{ 0x65, 0x64, 0x41, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x73, 0x5f, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x69, - 0x73, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x32, 0xdf, 0x02, 0x0a, 0x0c, 0x43, 0x61, - 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x12, 0x3e, 0x0a, 0x05, 0x53, 0x74, - 0x6f, 0x72, 0x65, 0x12, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x43, + 0x73, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x22, 0x56, 0x0a, 0x18, 0x4c, 0x69, 0x73, + 0x74, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3a, 0x0a, 0x0c, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, + 0x63, 0x61, 0x74, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x70, + 0x69, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, + 0x61, 0x74, 0x65, 0x52, 0x0c, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, + 0x73, 0x22, 0x5c, 0x0a, 0x11, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x43, 0x65, 0x72, 0x74, 0x69, + 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x61, 0x6e, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x73, 0x61, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x68, 0x61, 0x69, + 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x12, 0x1f, + 0x0a, 0x0b, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x44, 0x61, 0x74, 0x61, 0x32, + 0xaa, 0x03, 0x0a, 0x0c, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, + 0x12, 0x3e, 0x0a, 0x05, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x53, 0x74, 0x6f, 0x72, 0x65, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, + 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, + 0x12, 0x43, 0x0a, 0x04, 0x4c, 0x6f, 0x61, 0x64, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, + 0x6f, 0x61, 0x64, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x61, + 0x64, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, + 0x1e, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x61, 0x64, 0x64, + 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x43, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12, + 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, + 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, + 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x04, + 0x53, 0x74, 0x61, 0x74, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x43, 0x0a, 0x04, 0x4c, 0x6f, - 0x61, 0x64, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x61, 0x64, 0x43, 0x61, 0x64, - 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x6f, 0x61, 0x64, 0x43, 0x61, 0x64, 0x64, 0x79, - 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x40, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x1e, 0x2e, 0x61, 0x70, 0x69, 0x2e, - 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, - 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, - 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, - 0x79, 0x12, 0x43, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, - 0x4c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, - 0x73, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x04, 0x53, 0x74, 0x61, 0x74, 0x12, 0x1c, - 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, - 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, - 0x70, 0x69, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, - 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x26, 0x5a, 0x24, 0x67, - 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64, 0x65, - 0x72, 0x73, 0x6b, 0x69, 0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x61, 0x70, 0x69, - 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x43, 0x61, 0x64, + 0x64, 0x79, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x49, 0x0a, 0x10, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, + 0x63, 0x61, 0x74, 0x65, 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1d, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, + 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x26, 0x5a, 0x24, + 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64, + 0x65, 0x72, 0x73, 0x6b, 0x69, 0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x61, 0x70, + 0x69, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -532,7 +662,7 @@ func file_api_pb_caddy_storage_proto_rawDescGZIP() []byte { return file_api_pb_caddy_storage_proto_rawDescData } -var file_api_pb_caddy_storage_proto_msgTypes = make([]protoimpl.MessageInfo, 8) +var file_api_pb_caddy_storage_proto_msgTypes = make([]protoimpl.MessageInfo, 10) var file_api_pb_caddy_storage_proto_goTypes = []any{ (*StoreCaddyStorageRequest)(nil), // 0: api.StoreCaddyStorageRequest (*LoadCaddyStorageRequest)(nil), // 1: api.LoadCaddyStorageRequest @@ -542,27 +672,32 @@ var file_api_pb_caddy_storage_proto_goTypes = []any{ (*ListCaddyStorageResponse)(nil), // 5: api.ListCaddyStorageResponse (*StatCaddyStorageRequest)(nil), // 6: api.StatCaddyStorageRequest (*StatCaddyStorageResponse)(nil), // 7: api.StatCaddyStorageResponse - (*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp - (*emptypb.Empty)(nil), // 9: google.protobuf.Empty + (*ListCertificatesResponse)(nil), // 8: api.ListCertificatesResponse + (*IssuedCertificate)(nil), // 9: api.IssuedCertificate + (*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp + (*emptypb.Empty)(nil), // 11: google.protobuf.Empty } var file_api_pb_caddy_storage_proto_depIdxs = []int32{ - 8, // 0: api.LoadCaddyStorageResponse.updated_at:type_name -> google.protobuf.Timestamp - 8, // 1: api.StatCaddyStorageResponse.updated_at:type_name -> google.protobuf.Timestamp - 0, // 2: api.CaddyStorage.Store:input_type -> api.StoreCaddyStorageRequest - 1, // 3: api.CaddyStorage.Load:input_type -> api.LoadCaddyStorageRequest - 3, // 4: api.CaddyStorage.Delete:input_type -> api.DeleteCaddyStorageRequest - 4, // 5: api.CaddyStorage.List:input_type -> api.ListCaddyStorageRequest - 6, // 6: api.CaddyStorage.Stat:input_type -> api.StatCaddyStorageRequest - 9, // 7: api.CaddyStorage.Store:output_type -> google.protobuf.Empty - 2, // 8: api.CaddyStorage.Load:output_type -> api.LoadCaddyStorageResponse - 9, // 9: api.CaddyStorage.Delete:output_type -> google.protobuf.Empty - 5, // 10: api.CaddyStorage.List:output_type -> api.ListCaddyStorageResponse - 7, // 11: api.CaddyStorage.Stat:output_type -> api.StatCaddyStorageResponse - 7, // [7:12] is the sub-list for method output_type - 2, // [2:7] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 10, // 0: api.LoadCaddyStorageResponse.updated_at:type_name -> google.protobuf.Timestamp + 10, // 1: api.StatCaddyStorageResponse.updated_at:type_name -> google.protobuf.Timestamp + 9, // 2: api.ListCertificatesResponse.certificates:type_name -> api.IssuedCertificate + 0, // 3: api.CaddyStorage.Store:input_type -> api.StoreCaddyStorageRequest + 1, // 4: api.CaddyStorage.Load:input_type -> api.LoadCaddyStorageRequest + 3, // 5: api.CaddyStorage.Delete:input_type -> api.DeleteCaddyStorageRequest + 4, // 6: api.CaddyStorage.List:input_type -> api.ListCaddyStorageRequest + 6, // 7: api.CaddyStorage.Stat:input_type -> api.StatCaddyStorageRequest + 11, // 8: api.CaddyStorage.ListCertificates:input_type -> google.protobuf.Empty + 11, // 9: api.CaddyStorage.Store:output_type -> google.protobuf.Empty + 2, // 10: api.CaddyStorage.Load:output_type -> api.LoadCaddyStorageResponse + 11, // 11: api.CaddyStorage.Delete:output_type -> google.protobuf.Empty + 5, // 12: api.CaddyStorage.List:output_type -> api.ListCaddyStorageResponse + 7, // 13: api.CaddyStorage.Stat:output_type -> api.StatCaddyStorageResponse + 8, // 14: api.CaddyStorage.ListCertificates:output_type -> api.ListCertificatesResponse + 9, // [9:15] is the sub-list for method output_type + 3, // [3:9] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name } func init() { file_api_pb_caddy_storage_proto_init() } @@ -667,6 +802,30 @@ func file_api_pb_caddy_storage_proto_init() { return nil } } + file_api_pb_caddy_storage_proto_msgTypes[8].Exporter = func(v any, i int) any { + switch v := v.(*ListCertificatesResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_api_pb_caddy_storage_proto_msgTypes[9].Exporter = func(v any, i int) any { + switch v := v.(*IssuedCertificate); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } type x struct{} out := protoimpl.TypeBuilder{ @@ -674,7 +833,7 @@ func file_api_pb_caddy_storage_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_api_pb_caddy_storage_proto_rawDesc, NumEnums: 0, - NumMessages: 8, + NumMessages: 10, NumExtensions: 0, NumServices: 1, }, diff --git a/api/pb/caddy_storage.proto b/api/pb/caddy_storage.proto index f62e28f8..690dff05 100644 --- a/api/pb/caddy_storage.proto +++ b/api/pb/caddy_storage.proto @@ -18,6 +18,10 @@ service CaddyStorage { rpc Delete(DeleteCaddyStorageRequest) returns (google.protobuf.Empty); rpc List(ListCaddyStorageRequest) returns (ListCaddyStorageResponse); rpc Stat(StatCaddyStorageRequest) returns (StatCaddyStorageResponse); + + // ListCertificates lists certificates issued by Caddy and stored in the cluster store. + // Unreadable entries and invalid resource metadata are skipped. Chains and issuer data are returned as stored. + rpc ListCertificates(google.protobuf.Empty) returns (ListCertificatesResponse); } message StoreCaddyStorageRequest { @@ -57,3 +61,17 @@ message StatCaddyStorageResponse { int64 size = 3; bool is_terminal = 4; } + +message ListCertificatesResponse { + repeated IssuedCertificate certificates = 1; +} + +message IssuedCertificate { + // Subject Alternative Name (SAN) of the certificate. Caddy doesn't issue certificates with multiple SANs. + string san = 1; + // The PEM-encoding of DER-encoded ASN.1 data for the cert or chain, leaf first. + bytes chain = 2; + // Any extra information associated with the certificate, usually provided by the issuer implementation. + // JSON-encoded, may be absent or null. + bytes issuer_data = 3; +} diff --git a/api/pb/caddy_storage_grpc.pb.go b/api/pb/caddy_storage_grpc.pb.go index 56c488ab..04458210 100644 --- a/api/pb/caddy_storage_grpc.pb.go +++ b/api/pb/caddy_storage_grpc.pb.go @@ -20,11 +20,12 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - CaddyStorage_Store_FullMethodName = "/api.CaddyStorage/Store" - CaddyStorage_Load_FullMethodName = "/api.CaddyStorage/Load" - CaddyStorage_Delete_FullMethodName = "/api.CaddyStorage/Delete" - CaddyStorage_List_FullMethodName = "/api.CaddyStorage/List" - CaddyStorage_Stat_FullMethodName = "/api.CaddyStorage/Stat" + CaddyStorage_Store_FullMethodName = "/api.CaddyStorage/Store" + CaddyStorage_Load_FullMethodName = "/api.CaddyStorage/Load" + CaddyStorage_Delete_FullMethodName = "/api.CaddyStorage/Delete" + CaddyStorage_List_FullMethodName = "/api.CaddyStorage/List" + CaddyStorage_Stat_FullMethodName = "/api.CaddyStorage/Stat" + CaddyStorage_ListCertificates_FullMethodName = "/api.CaddyStorage/ListCertificates" ) // CaddyStorageClient is the client API for CaddyStorage service. @@ -42,6 +43,9 @@ type CaddyStorageClient interface { Delete(ctx context.Context, in *DeleteCaddyStorageRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) List(ctx context.Context, in *ListCaddyStorageRequest, opts ...grpc.CallOption) (*ListCaddyStorageResponse, error) Stat(ctx context.Context, in *StatCaddyStorageRequest, opts ...grpc.CallOption) (*StatCaddyStorageResponse, error) + // ListCertificates lists certificates issued by Caddy and stored in the cluster store. + // Unreadable entries and invalid resource metadata are skipped. Chains and issuer data are returned as stored. + ListCertificates(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ListCertificatesResponse, error) } type caddyStorageClient struct { @@ -102,6 +106,16 @@ func (c *caddyStorageClient) Stat(ctx context.Context, in *StatCaddyStorageReque return out, nil } +func (c *caddyStorageClient) ListCertificates(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ListCertificatesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListCertificatesResponse) + err := c.cc.Invoke(ctx, CaddyStorage_ListCertificates_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // CaddyStorageServer is the server API for CaddyStorage service. // All implementations must embed UnimplementedCaddyStorageServer // for forward compatibility. @@ -117,6 +131,9 @@ type CaddyStorageServer interface { Delete(context.Context, *DeleteCaddyStorageRequest) (*emptypb.Empty, error) List(context.Context, *ListCaddyStorageRequest) (*ListCaddyStorageResponse, error) Stat(context.Context, *StatCaddyStorageRequest) (*StatCaddyStorageResponse, error) + // ListCertificates lists certificates issued by Caddy and stored in the cluster store. + // Unreadable entries and invalid resource metadata are skipped. Chains and issuer data are returned as stored. + ListCertificates(context.Context, *emptypb.Empty) (*ListCertificatesResponse, error) mustEmbedUnimplementedCaddyStorageServer() } @@ -142,6 +159,9 @@ func (UnimplementedCaddyStorageServer) List(context.Context, *ListCaddyStorageRe func (UnimplementedCaddyStorageServer) Stat(context.Context, *StatCaddyStorageRequest) (*StatCaddyStorageResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Stat not implemented") } +func (UnimplementedCaddyStorageServer) ListCertificates(context.Context, *emptypb.Empty) (*ListCertificatesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListCertificates not implemented") +} func (UnimplementedCaddyStorageServer) mustEmbedUnimplementedCaddyStorageServer() {} func (UnimplementedCaddyStorageServer) testEmbeddedByValue() {} @@ -253,6 +273,24 @@ func _CaddyStorage_Stat_Handler(srv interface{}, ctx context.Context, dec func(i return interceptor(ctx, in, info, handler) } +func _CaddyStorage_ListCertificates_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CaddyStorageServer).ListCertificates(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CaddyStorage_ListCertificates_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CaddyStorageServer).ListCertificates(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + // CaddyStorage_ServiceDesc is the grpc.ServiceDesc for CaddyStorage service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -280,6 +318,10 @@ var CaddyStorage_ServiceDesc = grpc.ServiceDesc{ MethodName: "Stat", Handler: _CaddyStorage_Stat_Handler, }, + { + MethodName: "ListCertificates", + Handler: _CaddyStorage_ListCertificates_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "api/pb/caddy_storage.proto", diff --git a/cmd/uc/caddy/config.go b/cmd/uc/caddy/config.go index 604b4fa3..972573d1 100644 --- a/cmd/uc/caddy/config.go +++ b/cmd/uc/caddy/config.go @@ -9,6 +9,7 @@ import ( "github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli/completion" "github.com/psviderski/uncloud/internal/cli/tui" + "github.com/psviderski/uncloud/pkg/client" "github.com/spf13/cobra" ) @@ -47,12 +48,7 @@ func runConfig(ctx context.Context, uncli *cli.CLI, opts configOptions) error { } defer clusterClient.Close() - if opts.machine != "" { - // If a specific machine is requested, use it to get the Caddy configuration. - ctx = clusterClient.ProxySingleMachineContext(ctx, opts.machine) - } - - config, err := clusterClient.Caddy.GetConfig(ctx, nil) + config, err := clusterClient.Caddy.Config(ctx, client.CaddyConfigOptions{Machine: opts.machine}) if err != nil { return fmt.Errorf("get Caddy config: %w", err) } diff --git a/cmd/uc/caddy/deploy.go b/cmd/uc/caddy/deploy.go index 02e5030a..357399b6 100644 --- a/cmd/uc/caddy/deploy.go +++ b/cmd/uc/caddy/deploy.go @@ -110,7 +110,9 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { placement := api.Placement{ Machines: cli.ExpandCommaSeparatedValues(opts.machines), } - d, err := clusterClient.NewCaddyDeployment(opts.image, caddyfile, placement) + d, err := clusterClient.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{ + Image: opts.image, Config: caddyfile, Placement: placement, + }) if err != nil { return fmt.Errorf("create caddy deployment: %w", err) } diff --git a/cmd/uc/machine/add.go b/cmd/uc/machine/add.go index 792fcc4b..577f00d7 100644 --- a/cmd/uc/machine/add.go +++ b/cmd/uc/machine/add.go @@ -219,7 +219,7 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine, fmt.Println() fmt.Println("Preparing Caddy deployment...") - d, err := clusterClient.NewCaddyDeployment(caddyImage, "", api.Placement{}) + d, err := clusterClient.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{Image: caddyImage}) if err != nil { return fmt.Errorf("create caddy deployment: %w", err) } diff --git a/cmd/uc/machine/init.go b/cmd/uc/machine/init.go index 9126162d..157121ed 100644 --- a/cmd/uc/machine/init.go +++ b/cmd/uc/machine/init.go @@ -16,7 +16,7 @@ import ( "github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/machine/cluster" "github.com/psviderski/uncloud/internal/machine/network" - "github.com/psviderski/uncloud/pkg/api" + "github.com/psviderski/uncloud/pkg/client" "github.com/spf13/cobra" ) @@ -221,17 +221,17 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM initOpts.WireguardEndpoints = endpoints } - client, err := uncli.InitCluster(ctx, initOpts) + clusterClient, err := uncli.InitCluster(ctx, initOpts) if err != nil { return err } - defer client.Close() + defer clusterClient.Close() // Since the cluster API needs a few moments to become ready after cluster initialisation, // we keep the user informed during this wait. We wait here even if no Caddy or DNS is requested // as the cluster needs to be ready so that commands such as 'uc machine ls' work immediately after init. err = tui.RunSpinner(ctx, "Waiting for the cluster to be ready...", func(ctx context.Context) error { - return client.WaitClusterReady(ctx, 1*time.Minute) + return clusterClient.WaitClusterReady(ctx, 1*time.Minute) }) if err != nil { return fmt.Errorf("wait for cluster to be ready: %w", err) @@ -245,7 +245,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM fmt.Println() if !opts.noDNS { - domain, err := client.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.dnsEndpoint}) + domain, err := clusterClient.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.dnsEndpoint}) if err != nil { return fmt.Errorf("reserve cluster domain in Uncloud DNS: %w", err) } @@ -253,7 +253,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM } if !opts.noCaddy { - d, err := client.NewCaddyDeployment("", "", api.Placement{}) + d, err := clusterClient.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{}) if err != nil { return fmt.Errorf("create caddy deployment: %w", err) } @@ -269,7 +269,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM } fmt.Println() - return caddy.UpdateDomainRecords(ctx, client, uncli.ProgressOut()) + return caddy.UpdateDomainRecords(ctx, clusterClient, uncli.ProgressOut()) } return nil diff --git a/experiment/go.mod b/experiment/go.mod index e8149581..92df06ab 100644 --- a/experiment/go.mod +++ b/experiment/go.mod @@ -1,6 +1,6 @@ module github.com/psviderski/uncloud/experiment -go 1.26 +go 1.26.0 require ( github.com/dgraph-io/badger/v3 v3.2103.5 @@ -18,10 +18,10 @@ require ( github.com/psviderski/uncloud v0.0.0 github.com/siderolabs/discovery-api v0.1.4 github.com/siderolabs/discovery-client v0.1.9 - go.uber.org/zap v1.27.0 - golang.org/x/net v0.43.0 + go.uber.org/zap v1.28.0 + golang.org/x/net v0.59.0 golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 - google.golang.org/grpc v1.74.2 + google.golang.org/grpc v1.81.0 ) require ( @@ -79,18 +79,18 @@ require ( github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 // indirect github.com/jbenet/goprocess v0.1.4 // indirect github.com/josharian/native v1.1.0 // indirect - github.com/klauspost/compress v1.18.0 // indirect - github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/klauspost/compress v1.19.1 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/libp2p/go-buffer-pool v0.1.0 // indirect github.com/libp2p/go-libp2p v0.35.4 // indirect github.com/libp2p/go-libp2p-pubsub v0.11.0 // indirect github.com/libp2p/go-msgio v0.3.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/socket v0.5.1 // indirect - github.com/miekg/dns v1.1.65 // indirect + github.com/miekg/dns v1.1.73 // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/mitchellh/cli v1.1.5 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect @@ -113,30 +113,30 @@ require ( github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/siderolabs/gen v0.4.8 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spf13/cast v1.7.0 // indirect github.com/vishvananda/netlink v1.3.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel v1.36.0 // indirect - go.opentelemetry.io/otel/metric v1.36.0 // indirect - go.opentelemetry.io/otel/trace v1.36.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/multierr v1.11.0 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect - golang.org/x/crypto v0.41.0 // indirect + golang.org/x/crypto v0.57.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect - golang.org/x/mod v0.27.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.28.0 // indirect - golang.org/x/time v0.11.0 // indirect - golang.org/x/tools v0.36.0 // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + golang.org/x/time v0.16.0 // indirect + golang.org/x/tools v0.50.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect - google.golang.org/protobuf v1.36.9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect + google.golang.org/protobuf v1.36.12 // indirect gotest.tools/v3 v3.5.2 // indirect gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect lukechampine.com/blake3 v1.3.0 // indirect diff --git a/experiment/go.sum b/experiment/go.sum index d962b9c7..4c25516d 100644 --- a/experiment/go.sum +++ b/experiment/go.sum @@ -302,8 +302,10 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= @@ -349,6 +351,7 @@ github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= @@ -368,6 +371,7 @@ github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKju github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= github.com/miekg/dns v1.1.65 h1:0+tIPHzUW0GCge7IiK3guGP57VAw7hoPDfApjkMD1Fc= github.com/miekg/dns v1.1.65/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck= +github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b h1:z78hV3sbSMAUoyUMM0I83AUIT6Hu17AWfgjzIbtrYFc= @@ -529,6 +533,7 @@ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPx github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= @@ -581,16 +586,20 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/dig v1.17.1 h1:Tga8Lz8PcYNsWsyHMZ1Vm0OQOUaJNDyvPImgbAu9YSc= go.uber.org/dig v1.17.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= @@ -607,6 +616,7 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8 go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -622,6 +632,7 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= @@ -635,6 +646,7 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -654,6 +666,7 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -666,6 +679,7 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -700,6 +714,7 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= @@ -711,8 +726,10 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -727,6 +744,7 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -745,6 +763,7 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= @@ -753,6 +772,7 @@ google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8 google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= +google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -764,6 +784,7 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/go.mod b/go.mod index b5e1a116..17c82ba8 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/google/uuid v1.6.0 github.com/jmoiron/sqlx v1.4.0 github.com/mattn/go-shellwords v1.0.12 + github.com/mholt/acmez/v3 v3.1.6 github.com/miekg/dns v1.1.73 github.com/mitchellh/mapstructure v1.5.0 github.com/moby/term v0.5.2 @@ -178,7 +179,6 @@ require ( github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect - github.com/mholt/acmez/v3 v3.1.6 // indirect github.com/miekg/pkcs11 v1.1.2 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect diff --git a/internal/machine/caddystorage/certificate.go b/internal/machine/caddystorage/certificate.go new file mode 100644 index 00000000..e7acf1e9 --- /dev/null +++ b/internal/machine/caddystorage/certificate.go @@ -0,0 +1,79 @@ +package caddystorage + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + + "github.com/caddyserver/certmagic" + "github.com/psviderski/uncloud/api/pb" + "github.com/psviderski/uncloud/internal/machine/store" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" +) + +// ListCertificates lists issued certificates from this machine's Caddy storage replica. +// Unreadable entries and invalid resource metadata are skipped. Chains and issuer data are returned as stored. +func (s *Server) ListCertificates(ctx context.Context, _ *emptypb.Empty) (*pb.ListCertificatesResponse, error) { + records, err := s.store.List(ctx, "certificates/", store.KeyspaceListOptions{KeysOnly: true}) + if err != nil { + if ctx.Err() != nil { + return nil, status.FromContextError(ctx.Err()).Err() + } + return nil, status.Errorf(codes.Internal, "list Caddy certificates: %v", err) + } + + resp := &pb.ListCertificatesResponse{} + for _, record := range records { + if err = ctx.Err(); err != nil { + return nil, status.FromContextError(err).Err() + } + if !isCertificateKey(record.Key) { + continue + } + cert, err := s.loadIssuedCertificate(ctx, record.Key) + if err != nil { + slog.Warn("Failed to load issued certificate from Caddy storage.", "key", record.Key, "err", err) + continue + } + resp.Certificates = append(resp.Certificates, cert) + } + + return resp, nil +} + +// isCertificateKey recognizes CertMagic's certificates///.crt layout. +func isCertificateKey(key string) bool { + parts := strings.Split(key, "/") + return len(parts) == 4 && parts[0] == "certificates" && parts[3] == parts[2]+".crt" +} + +func (s *Server) loadIssuedCertificate(ctx context.Context, key string) (*pb.IssuedCertificate, error) { + chain, err := s.store.Get(ctx, key) + if err != nil { + return nil, err + } + + metaKey := strings.TrimSuffix(key, ".crt") + ".json" + meta, err := s.store.Get(ctx, metaKey) + if err != nil { + return nil, err + } + + var resource certmagic.CertificateResource + if err = json.Unmarshal(meta.Value, &resource); err != nil { + return nil, err + } + if len(resource.SANs) == 0 || strings.TrimSpace(resource.SANs[0]) == "" { + return nil, fmt.Errorf("certificate metadata has no SAN") + } + + return &pb.IssuedCertificate{ + San: resource.SANs[0], + Chain: chain.Value, + IssuerData: resource.IssuerData, + }, nil +} diff --git a/internal/machine/caddystorage/certificate_test.go b/internal/machine/caddystorage/certificate_test.go new file mode 100644 index 00000000..513252f3 --- /dev/null +++ b/internal/machine/caddystorage/certificate_test.go @@ -0,0 +1,233 @@ +package caddystorage + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "errors" + "math/big" + "net" + "slices" + "strings" + "testing" + "time" + + "github.com/caddyserver/certmagic" + "github.com/psviderski/uncloud/internal/machine/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" +) + +func TestServerListCertificates(t *testing.T) { + for _, sans := range [][]string{ + {"app.example.com"}, {"*.example.com"}, {"192.0.2.1"}, {"www.example.com", "example.com"}, + } { + t.Run(sans[0], func(t *testing.T) { + bundle, _, _ := certificateFixture(t, &x509.Certificate{ + DNSNames: []string{"example.com", "www.example.com"}, + IPAddresses: []net.IP{net.ParseIP("192.0.2.1")}, + NotAfter: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), + }) + key := certmagic.StorageKeys.SiteCert("local", sans[0]) + storage := newCertificateStorage(t) + raw := json.RawMessage(`{"unknown_issuer_field":true}`) + storage.add(t, key, bundle, sans, raw) + storage.keys = append(storage.keys, "certificates", "certificates/local", "certificates/local/app.crt", + "certificates/local/app/wrong.crt", "certificates/local/app/app.crt/extra.crt", + "pki/authorities/local/root.crt", "ocsp/certificate") + + resp, err := NewServer(storage).ListCertificates(t.Context(), &emptypb.Empty{}) + require.NoError(t, err) + require.Len(t, resp.Certificates, 1) + cert := resp.Certificates[0] + assert.Equal(t, sans[0], cert.San) + assert.Equal(t, bundle, cert.Chain, "PEM must be returned unchanged") + assert.Equal(t, []byte(raw), cert.IssuerData) + assert.Equal(t, []string{key, strings.TrimSuffix(key, ".crt") + ".json"}, storage.loads) + }) + } +} + +func TestServerListCertificates_PreservesIssuerData(t *testing.T) { + bundle, _, _ := certificateFixture(t, &x509.Certificate{}) + for _, raw := range []string{ + "", "null", `{"url":"https://ca/cert/1","ca":"https://ca/directory"}`, + `{"url":"https://ca/cert/1","ca":"https://ca/directory","renewal_info":{"_selectedTime":"invalid"}}`, + `"provider-record"`, "[1,2,3]", + } { + t.Run(raw, func(t *testing.T) { + storage := newCertificateStorage(t) + storage.add(t, "certificates/custom/app/app.crt", bundle, []string{"app"}, json.RawMessage(raw)) + + resp, err := NewServer(storage).ListCertificates(t.Context(), &emptypb.Empty{}) + require.NoError(t, err) + require.Len(t, resp.Certificates, 1) + assert.Equal(t, raw, string(resp.Certificates[0].IssuerData)) + }) + } +} + +func TestServerListCertificates_OrderingAndDuplicates(t *testing.T) { + bundle, _, _ := certificateFixture(t, &x509.Certificate{}) + storage := newCertificateStorage(t) + storage.add(t, certmagic.StorageKeys.SiteCert("z-issuer", "a.example.com"), bundle, []string{"a.example.com"}, nil) + storage.add(t, certmagic.StorageKeys.SiteCert("a-issuer", "z.example.com"), bundle, []string{"z.example.com"}, nil) + storage.add(t, certmagic.StorageKeys.SiteCert("b-issuer", "z.example.com"), bundle, []string{"z.example.com"}, nil) + + resp, err := NewServer(storage).ListCertificates(t.Context(), &emptypb.Empty{}) + require.NoError(t, err) + require.Len(t, resp.Certificates, 3) + certs := resp.Certificates + assert.Equal(t, []string{"z.example.com", "z.example.com", "a.example.com"}, + []string{certs[0].San, certs[1].San, certs[2].San}) + for _, cert := range certs { + assert.Equal(t, bundle, cert.Chain) + } +} + +func TestServerListCertificates_SkipsUnreadableEntries(t *testing.T) { + bundle, _, _ := certificateFixture(t, &x509.Certificate{}) + const badKey = "certificates/local/bad/bad.crt" + const metaKey = "certificates/local/bad/bad.json" + for _, tt := range []struct { + name string + change func(*certificateStorageStub) + }{ + {"missing chain", func(s *certificateStorageStub) { delete(s.values, badKey) }}, + {"chain read failure", func(s *certificateStorageStub) { s.loadErrors[badKey] = errors.New("offline") }}, + {"missing metadata", func(s *certificateStorageStub) { delete(s.values, metaKey) }}, + {"metadata read failure", func(s *certificateStorageStub) { s.loadErrors[metaKey] = errors.New("offline") }}, + {"invalid JSON", func(s *certificateStorageStub) { s.values[metaKey] = []byte("{") }}, + {"missing SAN", func(s *certificateStorageStub) { s.values[metaKey] = []byte(`{}`) }}, + {"empty first SAN", func(s *certificateStorageStub) { s.values[metaKey] = []byte(`{"sans":["","app"]}`) }}, + {"blank first SAN", func(s *certificateStorageStub) { s.values[metaKey] = []byte(`{"sans":[" "]}`) }}, + } { + t.Run(tt.name, func(t *testing.T) { + storage := newCertificateStorage(t) + storage.add(t, badKey, bundle, []string{"bad"}, nil) + storage.add(t, "certificates/local/good/good.crt", bundle, []string{"good"}, nil) + tt.change(storage) + + resp, err := NewServer(storage).ListCertificates(t.Context(), &emptypb.Empty{}) + require.NoError(t, err) + require.Len(t, resp.Certificates, 1) + assert.Equal(t, "good", resp.Certificates[0].San) + }) + } + + t.Run("all entries unreadable", func(t *testing.T) { + storage := newCertificateStorage(t) + storage.keys = []string{badKey} + resp, err := NewServer(storage).ListCertificates(t.Context(), &emptypb.Empty{}) + require.NoError(t, err) + assert.Empty(t, resp.Certificates) + }) +} + +func TestServerListCertificates_ListErrors(t *testing.T) { + for _, fail := range []bool{false, true} { + storage := newCertificateStorage(t) + if fail { + storage.listError = errors.New("offline") + } + + resp, err := NewServer(storage).ListCertificates(t.Context(), &emptypb.Empty{}) + assert.Empty(t, storage.loads) + if fail { + assert.Nil(t, resp) + assert.Equal(t, codes.Internal, status.Code(err)) + } else { + require.NoError(t, err) + assert.Empty(t, resp.Certificates) + } + } +} + +// Only reads are implemented. Unexpected writes panic through the nil embedded Keyspace. +type certificateStorageStub struct { + *store.Keyspace + t *testing.T + keys []string + values map[string][]byte + loads []string + listCalls int + listError error + loadErrors map[string]error + onLoad func(string) +} + +func newCertificateStorage(t *testing.T) *certificateStorageStub { + return &certificateStorageStub{t: t, values: make(map[string][]byte), loadErrors: make(map[string]error)} +} + +func (s *certificateStorageStub) add(t *testing.T, key string, bundle []byte, sans []string, issuerData json.RawMessage) { + t.Helper() + metaKey := strings.TrimSuffix(key, ".crt") + ".json" + keyKey := strings.TrimSuffix(key, ".crt") + ".key" + meta, err := json.Marshal(certmagic.CertificateResource{SANs: sans, IssuerData: issuerData}) + require.NoError(t, err) + s.keys = append(s.keys, key, metaKey, keyKey) + s.values[key], s.values[metaKey], s.values[keyKey] = bundle, meta, []byte("must not read private keys") +} + +func (s *certificateStorageStub) List(ctx context.Context, prefix string, opts store.KeyspaceListOptions) ([]store.Record, error) { + s.listCalls++ + assert.Equal(s.t, "certificates/", prefix) + assert.True(s.t, opts.KeysOnly, "listing must not fetch private key values") + var records []store.Record + for _, key := range slices.Sorted(slices.Values(s.keys)) { + records = append(records, store.Record{Key: key}) + } + return records, s.listError +} + +func (s *certificateStorageStub) Get(ctx context.Context, key string) (store.Record, error) { + require.False(s.t, strings.HasSuffix(key, ".key"), "private keys must never be loaded") + s.loads = append(s.loads, key) + if s.onLoad != nil { + s.onLoad(key) + } + if err := s.loadErrors[key]; err != nil { + return store.Record{}, err + } + value, ok := s.values[key] + if !ok { + return store.Record{}, store.ErrKeyNotFound + } + return store.Record{Key: key, Value: value}, nil +} + +func certificateFixture(t *testing.T, template *x509.Certificate) ([]byte, *x509.Certificate, *x509.Certificate) { + t.Helper() + publicKey, key, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + root := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Test CA"}, IsCA: true, + BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign, + NotBefore: time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2040, 1, 1, 0, 0, 0, 0, time.UTC), + } + rootDER, err := x509.CreateCertificate(rand.Reader, root, root, publicKey, key) + require.NoError(t, err) + root, err = x509.ParseCertificate(rootDER) + require.NoError(t, err) + template.SerialNumber = big.NewInt(2) + template.NotBefore = root.NotBefore + if template.NotAfter.IsZero() { + template.NotAfter = root.NotAfter + } + leafDER, err := x509.CreateCertificate(rand.Reader, template, root, publicKey, key) + require.NoError(t, err) + leaf, err := x509.ParseCertificate(leafDER) + require.NoError(t, err) + bundle := append(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER}), + pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootDER})...) + return bundle, leaf, root +} diff --git a/internal/machine/caddystorage/server.go b/internal/machine/caddystorage/server.go index db48d10e..988c23b7 100644 --- a/internal/machine/caddystorage/server.go +++ b/internal/machine/caddystorage/server.go @@ -25,10 +25,17 @@ const Namespace = "caddy_storage" // Server implements the machine-local CaddyStorage gRPC service. type Server struct { pb.UnimplementedCaddyStorageServer - store *store.Keyspace + store keyspace } -func NewServer(store *store.Keyspace) *Server { +type keyspace interface { + Get(context.Context, string) (store.Record, error) + Put(context.Context, string, []byte) error + Delete(context.Context, string, store.KeyspaceDeleteOptions) error + List(context.Context, string, store.KeyspaceListOptions) ([]store.Record, error) +} + +func NewServer(store keyspace) *Server { return &Server{store: store} } diff --git a/pkg/api/caddy.go b/pkg/api/caddy.go index 0f06017c..ba5ad57b 100644 --- a/pkg/api/caddy.go +++ b/pkg/api/caddy.go @@ -1,6 +1,18 @@ package api -import "strings" +import ( + "strings" + "time" +) + +// CaddyConfig is the saved Caddy configuration on a machine. +type CaddyConfig struct { + Caddyfile string + // ModifiedAt is zero when the server does not supply a modification timestamp. + ModifiedAt time.Time + // LastReconciliationError describes the latest unsuccessful configuration reconciliation. + LastReconciliationError string +} // CaddySpec is the Caddy reverse proxy configuration for a service. type CaddySpec struct { diff --git a/pkg/api/caddy_certificate.go b/pkg/api/caddy_certificate.go new file mode 100644 index 00000000..3b9a62ab --- /dev/null +++ b/pkg/api/caddy_certificate.go @@ -0,0 +1,105 @@ +package api + +import ( + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "strings" + + "github.com/mholt/acmez/v3/acme" + "github.com/psviderski/uncloud/api/pb" +) + +// IssuedCertificate describes a certificate in Caddy's managed certificate storage. +// It does not indicate whether Caddy currently serves the certificate or whether it is trusted. +type IssuedCertificate struct { + // SAN is the Subject Alternative Name (SAN) of the certificate. + // Caddy doesn't issue certificates with multiple SANs. + SAN string + // Chain contains the parsed certificates in stored order, with the leaf first. + Chain []*x509.Certificate + // IssuerData is extra information associated with the certificate, usually provided by the issuer implementation. + IssuerData CertificateIssuerData +} + +// CertificateIssuerData preserves issuer-specific metadata and provides a best-effort typed view of ACME records. +type CertificateIssuerData struct { + // Raw is the original issuer_data JSON, including unrecognized formats and fields. + Raw json.RawMessage + // ACME is populated when the metadata can be decoded as an ACME record with CA and certificate URLs. + // It is nil for absent, unrecognized, or malformed metadata. + ACME *ACMEIssuerData +} + +// ACMEIssuerData identifies the ACME resources used to issue a certificate. +type ACMEIssuerData struct { + // URL is the certificate resource URL as provisioned by the ACME server. + URL string + // CA is the directory URL of the ACME CA that issued this certificate. + CA string + // Account is the URL of the account that obtained the certificate. + Account string + // RenewalInfo is the stored renewal guidance, not a guarantee of when renewal will run. + RenewalInfo *acme.RenewalInfo +} + +// IssuedCertificateFromProto parses a stored certificate chain without verifying trust or expiry. +// Issuer metadata is decoded as ACME on a best-effort basis and is always preserved in its raw form. +func IssuedCertificateFromProto(p *pb.IssuedCertificate) (IssuedCertificate, error) { + if p == nil || strings.TrimSpace(p.San) == "" { + return IssuedCertificate{}, fmt.Errorf("invalid certificate: missing SAN") + } + chain, err := parseCertificateChain(p.Chain) + if err != nil { + return IssuedCertificate{}, fmt.Errorf("parse certificate '%s': %w", p.San, err) + } + return IssuedCertificate{ + SAN: p.San, + Chain: chain, + IssuerData: parseCertificateIssuerData(p.IssuerData), + }, nil +} + +func parseCertificateChain(data []byte) ([]*x509.Certificate, error) { + var chain []*x509.Certificate + for len(data) > 0 { + var block *pem.Block + block, data = pem.Decode(data) + if block == nil { + break + } + if block.Type != "CERTIFICATE" { + return nil, fmt.Errorf("unexpected PEM block type '%s'", block.Type) + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse X.509 certificate: %w", err) + } + chain = append(chain, cert) + } + + if len(chain) == 0 { + return nil, fmt.Errorf("empty certificate chain") + } + + return chain, nil +} + +func parseCertificateIssuerData(raw json.RawMessage) CertificateIssuerData { + data := CertificateIssuerData{Raw: raw} + // Recognize ACME by its resource URLs and decodable metadata. + // Unknown formats and malformed records remain raw-only. + var cert acme.Certificate + if err := json.Unmarshal(raw, &cert); err != nil || cert.CA == "" || cert.URL == "" { + return data + } + data.ACME = &ACMEIssuerData{ + URL: cert.URL, + CA: cert.CA, + Account: cert.Account, + RenewalInfo: cert.RenewalInfo, + } + return data +} diff --git a/pkg/api/caddy_certificate_test.go b/pkg/api/caddy_certificate_test.go new file mode 100644 index 00000000..a8c36a59 --- /dev/null +++ b/pkg/api/caddy_certificate_test.go @@ -0,0 +1,158 @@ +package api + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "math/big" + "testing" + "time" + + "github.com/mholt/acmez/v3/acme" + "github.com/psviderski/uncloud/api/pb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIssuedCertificateFromProto(t *testing.T) { + leaf := testCertificatePEM(t, 1) + issuer := testCertificatePEM(t, 2) + for _, tt := range []struct { + name string + chain []byte + serials []int64 + }{ + {name: "single certificate", chain: leaf, serials: []int64{1}}, + {name: "certificate chain", chain: append(append([]byte(nil), leaf...), issuer...), serials: []int64{1, 2}}, + } { + t.Run(tt.name, func(t *testing.T) { + cert, err := IssuedCertificateFromProto(&pb.IssuedCertificate{ + San: "app.example.com", Chain: tt.chain, + }) + require.NoError(t, err) + assert.Equal(t, "app.example.com", cert.SAN) + require.Len(t, cert.Chain, len(tt.serials)) + + for i, serial := range tt.serials { + assert.Equal(t, big.NewInt(serial), cert.Chain[i].SerialNumber, "chain order must be preserved") + } + assert.Equal(t, []string{"app.example.com"}, cert.Chain[0].DNSNames) + // Expired, self-signed certificates are parsed without verifying trust or validity. + assert.Equal(t, time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), cert.Chain[0].NotAfter) + assert.Empty(t, cert.IssuerData) + }) + } +} + +func TestIssuedCertificateFromProto_InvalidCertificate(t *testing.T) { + valid := testCertificatePEM(t, 1) + invalidDER := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("invalid")}) + for _, tt := range []struct { + name string + input *pb.IssuedCertificate + wantErr string + }{ + {name: "nil input", wantErr: "missing SAN"}, + {name: "missing SAN", input: &pb.IssuedCertificate{Chain: valid}, wantErr: "missing SAN"}, + {name: "blank SAN", input: &pb.IssuedCertificate{San: " \t", Chain: valid}, wantErr: "missing SAN"}, + {name: "empty chain", input: &pb.IssuedCertificate{San: "app.example.com"}, wantErr: "empty certificate chain"}, + {name: "invalid PEM", input: &pb.IssuedCertificate{San: "app.example.com", Chain: []byte("not PEM")}, + wantErr: "empty certificate chain"}, + {name: "wrong PEM block type", input: &pb.IssuedCertificate{San: "app.example.com", + Chain: pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: []byte("key")})}, + wantErr: "unexpected PEM block type"}, + {name: "invalid DER", input: &pb.IssuedCertificate{San: "app.example.com", Chain: invalidDER}, + wantErr: "parse X.509 certificate"}, + {name: "invalid certificate after valid leaf", input: &pb.IssuedCertificate{San: "app.example.com", + Chain: append(append([]byte(nil), valid...), invalidDER...)}, wantErr: "parse X.509 certificate"}, + } { + t.Run(tt.name, func(t *testing.T) { + cert, err := IssuedCertificateFromProto(tt.input) + require.ErrorContains(t, err, tt.wantErr) + assert.Empty(t, cert) + }) + } +} + +func TestIssuedCertificateFromProto_IssuerData(t *testing.T) { + chain := testCertificatePEM(t, 1) + retryAfter := time.Date(2029, 12, 1, 0, 0, 0, 0, time.UTC) + renewalInfo := &acme.RenewalInfo{ + ExplanationURL: "https://ca.example/why", + UniqueIdentifier: "aki.serial", + RetryAfter: &retryAfter, + SelectedTime: time.Date(2030, 1, 2, 0, 0, 0, 0, time.UTC), + } + renewalInfo.SuggestedWindow.Start = time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) + renewalInfo.SuggestedWindow.End = time.Date(2030, 1, 3, 0, 0, 0, 0, time.UTC) + + for _, tt := range []struct { + name string + raw string + want *ACMEIssuerData + }{ + { + name: "ACME", + raw: `{"url":"https://ca.example/cert/1","ca":"https://ca.example/directory","account":"https://ca.example/acct/1","future_field":true}`, + want: &ACMEIssuerData{ + URL: "https://ca.example/cert/1", CA: "https://ca.example/directory", Account: "https://ca.example/acct/1", + }, + }, + { + name: "ACME with renewal information", + raw: `{ + "url": "https://ca.example/cert/1", + "ca": "https://ca.example/directory", + "account": "https://ca.example/acct/1", + "renewal_info": { + "suggestedWindow": {"start": "2030-01-01T00:00:00Z", "end": "2030-01-03T00:00:00Z"}, + "explanationURL": "https://ca.example/why", + "_uniqueIdentifier": "aki.serial", + "_retryAfter": "2029-12-01T00:00:00Z", + "_selectedTime": "2030-01-02T00:00:00Z" + } + }`, + want: &ACMEIssuerData{ + URL: "https://ca.example/cert/1", CA: "https://ca.example/directory", + Account: "https://ca.example/acct/1", RenewalInfo: renewalInfo, + }, + }, + {name: "absent"}, + {name: "null", raw: "null"}, + {name: "unknown issuer", raw: `{"id":"provider-id","status":"issued"}`}, + {name: "non-object JSON", raw: `"provider-record"`}, + {name: "missing CA", raw: `{"url":"https://ca.example/cert/1"}`}, + {name: "missing certificate URL", raw: `{"ca":"https://ca.example/directory"}`}, + {name: "invalid JSON", raw: "{"}, + {name: "invalid field types", raw: `{"url":123,"ca":false}`}, + {name: "malformed renewal information", + raw: `{"url":"https://ca.example/cert/1","ca":"https://ca.example/directory","renewal_info":{"_selectedTime":"invalid"}}`}, + } { + t.Run(tt.name, func(t *testing.T) { + cert, err := IssuedCertificateFromProto(&pb.IssuedCertificate{ + San: "app.example.com", Chain: chain, IssuerData: []byte(tt.raw), + }) + require.NoError(t, err, "issuer metadata must not prevent certificate parsing") + require.Len(t, cert.Chain, 1) + assert.Equal(t, tt.raw, string(cert.IssuerData.Raw), "preserve original JSON including unknown fields") + assert.Equal(t, tt.want, cert.IssuerData.ACME) + }) + } +} + +// testCertificatePEM creates an expired, self-signed certificate for parsing tests. +func testCertificatePEM(t *testing.T, serial int64) []byte { + t.Helper() + public, private, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + template := &x509.Certificate{ + SerialNumber: big.NewInt(serial), + DNSNames: []string{"app.example.com"}, + NotBefore: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + } + der, err := x509.CreateCertificate(rand.Reader, template, template, public, private) + require.NoError(t, err) + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} diff --git a/pkg/client/caddy.go b/pkg/client/caddy.go index 2acc0881..4f903cdf 100644 --- a/pkg/client/caddy.go +++ b/pkg/client/caddy.go @@ -1,6 +1,7 @@ package client import ( + "context" "fmt" "regexp" @@ -8,8 +9,10 @@ import ( "github.com/distribution/reference" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/psviderski/uncloud/api/pb" "github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/client/deploy" + "google.golang.org/protobuf/types/known/emptypb" ) const ( @@ -20,12 +23,65 @@ const ( var caddyImageTagRegex = regexp.MustCompile(`^2\.\d+\.\d+$`) -// NewCaddyDeployment creates a new deployment for a Caddy reverse proxy service. +// CaddyClient provides Caddy operations over its parent Client's connection. +// The parent client owns the connection and must be used to close it. +type CaddyClient struct { + // Storage provides low-level access to Caddy's cluster-backed storage. + Storage pb.CaddyStorageClient + grpc pb.CaddyClient + client *Client +} + +// CaddyConfigOptions controls which machine's saved Caddy configuration is retrieved. +type CaddyConfigOptions struct { + // Machine is the machine name or ID. If empty, the configuration is retrieved from the machine the client + // is connected to. + Machine string +} + +// Config retrieves the saved Caddy configuration from the machine selected by opts. +func (c *CaddyClient) Config(ctx context.Context, opts CaddyConfigOptions) (api.CaddyConfig, error) { + if opts.Machine != "" { + ctx = ProxySingleMachineContext(ctx, opts.Machine) + } + + resp, err := c.grpc.GetConfig(ctx, &emptypb.Empty{}) + if err != nil { + return api.CaddyConfig{}, fmt.Errorf("get Caddy config: %w", err) + } + config := api.CaddyConfig{ + Caddyfile: resp.Caddyfile, + LastReconciliationError: resp.LastReconciliationError, + } + if resp.ModifiedAt != nil { + if err := resp.ModifiedAt.CheckValid(); err != nil { + return api.CaddyConfig{}, fmt.Errorf("invalid Caddy config modification timestamp: %w", err) + } + config.ModifiedAt = resp.ModifiedAt.AsTime() + } + + return config, nil +} + +// CaddyDeploymentOptions configures a Caddy reverse proxy deployment. +type CaddyDeploymentOptions struct { + // Image defaults to the latest stable 2.x.x official Caddy image. + Image string + // Config contains an optional global Caddyfile. + Config string + Placement api.Placement +} + +// NewDeployment creates a new deployment for a Caddy reverse proxy service. // The service is deployed in global mode to all machines in the cluster. If the image is not provided, the latest // version of the official Caddy Docker image is used. -func (cli *Client) NewCaddyDeployment(image, config string, placement api.Placement) (*deploy.Deployment, error) { +func (c *CaddyClient) NewDeployment(ctx context.Context, opts CaddyDeploymentOptions) (*deploy.Deployment, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + image := opts.Image if image == "" { - latest, err := LatestCaddyImage() + latest, err := latestCaddyImage(ctx) if err != nil { return nil, fmt.Errorf("look up latest Caddy image: %w", err) } @@ -62,7 +118,7 @@ func (cli *Client) NewCaddyDeployment(image, config string, placement api.Placem }, Mode: api.ServiceModeGlobal, Name: CaddyServiceName, - Placement: placement, + Placement: opts.Placement, Ports: []api.PortSpec{ { PublishedPort: 80, @@ -113,28 +169,44 @@ func (cli *Client) NewCaddyDeployment(image, config string, placement api.Placem }, } - if config != "" { + if opts.Config != "" { spec.Caddy = &api.CaddySpec{ - Config: config, + Config: opts.Config, } } - return cli.NewDeployment(spec, nil), nil + return c.client.NewDeployment(spec, nil), nil } -// LatestCaddyImage returns the latest image of the official Caddy Docker image on Docker Hub. +// latestCaddyImage returns the latest image of the official Caddy Docker image on Docker Hub. // The latest image is determined by the latest version tag 2.x.x. -func LatestCaddyImage() (reference.NamedTagged, error) { +func latestCaddyImage(ctx context.Context) (reference.NamedTagged, error) { + if err := ctx.Err(); err != nil { + return nil, err + } repo, err := name.NewRepository(CaddyImage) if err != nil { return nil, fmt.Errorf("parse image: %w", err) } - tags, err := remote.List(repo) + tags, err := remote.List(repo, remote.WithContext(ctx)) if err != nil { return nil, fmt.Errorf("list image tags: %w", err) } - // Default to the 'latest' tag but try to find the latest version tag 2.x.x. + image, err := reference.ParseDockerRef(CaddyImage) + if err != nil { + return nil, fmt.Errorf("parse image: %w", err) + } + imageWithTag, err := reference.WithTag(image, latestCaddyTag(tags)) + if err != nil { + return nil, fmt.Errorf("set image tag: %w", err) + } + + return imageWithTag, nil +} + +// latestCaddyTag selects the newest stable 2.x.x tag, falling back to latest. +func latestCaddyTag(tags []string) string { latestTag := "latest" var latestVersion *semver.Version for _, t := range tags { @@ -152,14 +224,5 @@ func LatestCaddyImage() (reference.NamedTagged, error) { } } - image, err := reference.ParseDockerRef(CaddyImage) - if err != nil { - return nil, fmt.Errorf("parse image: %w", err) - } - imageWithTag, err := reference.WithTag(image, latestTag) - if err != nil { - return nil, fmt.Errorf("set image tag: %w", err) - } - - return imageWithTag, nil + return latestTag } diff --git a/pkg/client/caddy_certificate.go b/pkg/client/caddy_certificate.go new file mode 100644 index 00000000..cdb9d098 --- /dev/null +++ b/pkg/client/caddy_certificate.go @@ -0,0 +1,44 @@ +package client + +import ( + "context" + "errors" + + "github.com/psviderski/uncloud/pkg/api" + "google.golang.org/protobuf/types/known/emptypb" +) + +// CaddyListCertificatesOptions controls which machine's certificate storage replica is queried. +type CaddyListCertificatesOptions struct { + // Machine is the machine name or ID. If empty, the machine the client is connected to is used. + Machine string +} + +// ListCertificates lists issued certificates from the Caddy's managed certificate storage backed by the distributed +// cluster store. It doesn't verify trust or whether Caddy serves them and may include expired certificates. +// Private key assets are never read or returned. +// +// This is a non-atomic inventory of one store replica. It does not wait for replication. +// It returns parsing errors as a combined error, but still returns successfully read certificates regardless +// of errors. +func (c *CaddyClient) ListCertificates(ctx context.Context, opts CaddyListCertificatesOptions) ([]api.IssuedCertificate, error) { + if opts.Machine != "" { + ctx = ProxySingleMachineContext(ctx, opts.Machine) + } + resp, err := c.Storage.ListCertificates(ctx, &emptypb.Empty{}) + if err != nil { + return nil, err + } + + var certs []api.IssuedCertificate + var errs []error + for _, p := range resp.Certificates { + if cert, err := api.IssuedCertificateFromProto(p); err != nil { + errs = append(errs, err) + } else { + certs = append(certs, cert) + } + } + + return certs, errors.Join(errs...) +} diff --git a/pkg/client/caddy_test.go b/pkg/client/caddy_test.go index b0789db5..3b3eccd8 100644 --- a/pkg/client/caddy_test.go +++ b/pkg/client/caddy_test.go @@ -1,6 +1,7 @@ package client import ( + "context" "testing" "github.com/distribution/reference" @@ -11,8 +12,29 @@ import ( func TestLatestCaddyImage(t *testing.T) { t.Parallel() - image, err := LatestCaddyImage() + image, err := latestCaddyImage(context.Background()) require.NoError(t, err) assert.Regexp(t, `^caddy:2\.\d+\.\d+$`, reference.FamiliarString(image)) } + +func TestLatestCaddyTag(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + tags []string + want string + }{ + {name: "empty", want: "latest"}, + {name: "semantic ordering", tags: []string{"2.9.9", "2.11.4", "2.10.0", "2.11.3"}, want: "2.11.4"}, + {name: "ignore other versions and variants", tags: []string{ + "latest", "1.0.0", "3.0.0", "2.12.0-rc.1", "2.12.0-alpine", "2.12", "v2.12.0", "2.11.4", + }, want: "2.11.4"}, + {name: "no stable version", tags: []string{"builder", "2.12.0-beta.1"}, want: "latest"}, + } { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, latestCaddyTag(tt.tags)) + }) + } +} diff --git a/pkg/client/client.go b/pkg/client/client.go index f43e9eb1..43232fcd 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -25,8 +25,8 @@ type Client struct { // Methods such as Reset or Inspect are ambiguous in the context of a machine+cluster client. pb.MachineClient pb.ClusterClient - Caddy pb.CaddyClient - CaddyStorage pb.CaddyStorageClient + // Caddy provides Caddy configuration, deployment, and certificate storage operations. + Caddy *CaddyClient // Docker is a namespaced client for the Docker service to distinguish Uncloud-specific service container operations // from generic Docker operations. Docker *docker.Client @@ -58,8 +58,11 @@ func New(ctx context.Context, connector Connector) (*Client, error) { c.MachineClient = pb.NewMachineClient(c.conn) c.ClusterClient = pb.NewClusterClient(c.conn) - c.Caddy = pb.NewCaddyClient(c.conn) - c.CaddyStorage = pb.NewCaddyStorageClient(c.conn) + c.Caddy = &CaddyClient{ + Storage: pb.NewCaddyStorageClient(c.conn), + grpc: pb.NewCaddyClient(c.conn), + client: c, + } c.Docker = docker.NewClient(c.conn) c.leases = distlockgrpc.NewLeaseClient(c.conn) diff --git a/test/e2e/cluster_test.go b/test/e2e/cluster_test.go index 5e3c559c..1b833011 100644 --- a/test/e2e/cluster_test.go +++ b/test/e2e/cluster_test.go @@ -228,19 +228,19 @@ func TestClusterLifecycle(t *testing.T) { t.Cleanup(func() { cleanupCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - _, _ = clients[0].CaddyStorage.Delete(cleanupCtx, &pb.DeleteCaddyStorageRequest{Key: prefix}) - _, _ = clients[1].CaddyStorage.Delete(cleanupCtx, &pb.DeleteCaddyStorageRequest{Key: prefix}) + _, _ = clients[0].Caddy.Storage.Delete(cleanupCtx, &pb.DeleteCaddyStorageRequest{Key: prefix}) + _, _ = clients[1].Caddy.Storage.Delete(cleanupCtx, &pb.DeleteCaddyStorageRequest{Key: prefix}) }) // Create and overwrite a key on the first machine before waiting for replication. var updatedAt time.Time for _, value := range [][]byte{[]byte("test-value"), []byte("replacement-value")} { - _, err := clients[0].CaddyStorage.Store(ctx, &pb.StoreCaddyStorageRequest{Key: key, Value: value}) + _, err := clients[0].Caddy.Storage.Store(ctx, &pb.StoreCaddyStorageRequest{Key: key, Value: value}) require.NoError(t, err) // A Load through another machine must find the value on the machine that accepted the local write, // regardless of whether Corrosion has replicated it to the other machines yet. - loadResp, err := clients[1].CaddyStorage.Load(client.ProxySingleMachineContext(ctx, c.Machines[0].ID), + loadResp, err := clients[1].Caddy.Storage.Load(client.ProxySingleMachineContext(ctx, c.Machines[0].ID), &pb.LoadCaddyStorageRequest{Key: key}) require.NoError(t, err) require.Equal(t, value, loadResp.Value) @@ -256,7 +256,7 @@ func TestClusterLifecycle(t *testing.T) { // Write a distinct key on the second machine, then capture the combined store version from both machines. otherValue := []byte("second-value") - _, err := clients[1].CaddyStorage.Store(ctx, &pb.StoreCaddyStorageRequest{Key: otherKey, Value: otherValue}) + _, err := clients[1].Caddy.Storage.Store(ctx, &pb.StoreCaddyStorageRequest{Key: otherKey, Value: otherValue}) require.NoError(t, err) version := storeVersion(clients[0], clients[1]) values := map[string][]byte{key: []byte("replacement-value"), otherKey: otherValue} @@ -268,7 +268,7 @@ func TestClusterLifecycle(t *testing.T) { // Both final values must be readable locally as soon as the wait returns. for k, v := range values { - resp, err := cli.CaddyStorage.Load(ctx, &pb.LoadCaddyStorageRequest{Key: k}) + resp, err := cli.Caddy.Storage.Load(ctx, &pb.LoadCaddyStorageRequest{Key: k}) require.NoError(t, err) assert.Equal(t, v, resp.Value) require.NoError(t, resp.UpdatedAt.CheckValid()) @@ -276,7 +276,7 @@ func TestClusterLifecycle(t *testing.T) { assert.True(t, resp.UpdatedAt.AsTime().Equal(updatedAt)) } - statResp, err := cli.CaddyStorage.Stat(ctx, &pb.StatCaddyStorageRequest{Key: k}) + statResp, err := cli.Caddy.Storage.Stat(ctx, &pb.StatCaddyStorageRequest{Key: k}) require.NoError(t, err) assert.Equal(t, k, statResp.Key) assert.True(t, statResp.UpdatedAt.AsTime().Equal(resp.UpdatedAt.AsTime())) @@ -285,25 +285,25 @@ func TestClusterLifecycle(t *testing.T) { } // A path with descendants should exist as a directory even though no value is stored at that key. - statResp, err := cli.CaddyStorage.Stat(ctx, &pb.StatCaddyStorageRequest{Key: prefix + "/key"}) + statResp, err := cli.Caddy.Storage.Stat(ctx, &pb.StatCaddyStorageRequest{Key: prefix + "/key"}) require.NoError(t, err) assert.Equal(t, prefix+"/key", statResp.Key) assert.Nil(t, statResp.UpdatedAt) assert.EqualValues(t, 0, statResp.Size) assert.False(t, statResp.IsTerminal) - listResp, err := cli.CaddyStorage.List(ctx, &pb.ListCaddyStorageRequest{Prefix: prefix, Recursive: true}) + listResp, err := cli.Caddy.Storage.List(ctx, &pb.ListCaddyStorageRequest{Prefix: prefix, Recursive: true}) require.NoError(t, err) assert.Equal(t, []string{prefix + "/key", key, otherKey}, listResp.Keys) // A non-recursive list should only return the immediate child keys. - listResp, err = cli.CaddyStorage.List(ctx, &pb.ListCaddyStorageRequest{Prefix: prefix, Recursive: false}) + listResp, err = cli.Caddy.Storage.List(ctx, &pb.ListCaddyStorageRequest{Prefix: prefix, Recursive: false}) require.NoError(t, err) assert.Equal(t, []string{prefix + "/key", otherKey}, listResp.Keys) } // Delete through one machine. The deletion must reach the other replicas through Corrosion. - _, err = clients[2].CaddyStorage.Delete(ctx, &pb.DeleteCaddyStorageRequest{Key: prefix}) + _, err = clients[2].Caddy.Storage.Delete(ctx, &pb.DeleteCaddyStorageRequest{Key: prefix}) require.NoError(t, err) version = storeVersion(clients[2]) @@ -311,16 +311,16 @@ func TestClusterLifecycle(t *testing.T) { waitForStoreVersion(cli, version) for k := range values { - _, err = cli.CaddyStorage.Load(ctx, &pb.LoadCaddyStorageRequest{Key: k}) + _, err = cli.Caddy.Storage.Load(ctx, &pb.LoadCaddyStorageRequest{Key: k}) assert.Equal(t, codes.NotFound, status.Code(err)) - _, err = cli.CaddyStorage.Stat(ctx, &pb.StatCaddyStorageRequest{Key: k}) + _, err = cli.Caddy.Storage.Stat(ctx, &pb.StatCaddyStorageRequest{Key: k}) assert.Equal(t, codes.NotFound, status.Code(err)) } - _, err = cli.CaddyStorage.List(ctx, &pb.ListCaddyStorageRequest{Prefix: prefix, Recursive: true}) + _, err = cli.Caddy.Storage.List(ctx, &pb.ListCaddyStorageRequest{Prefix: prefix, Recursive: true}) assert.Equal(t, codes.NotFound, status.Code(err)) // Delete is idempotent on each machine. - _, err = cli.CaddyStorage.Delete(ctx, &pb.DeleteCaddyStorageRequest{Key: prefix}) + _, err = cli.Caddy.Storage.Delete(ctx, &pb.DeleteCaddyStorageRequest{Key: prefix}) require.NoError(t, err) } }) diff --git a/test/e2e/machine_test.go b/test/e2e/machine_test.go index a70765d7..47c70ce1 100644 --- a/test/e2e/machine_test.go +++ b/test/e2e/machine_test.go @@ -459,8 +459,8 @@ func TestMachineOperations(t *testing.T) { t.Run("remove machine clears container records from cluster store", func(t *testing.T) { // The Caddy controller needs a local Caddy container to supply the global config before it can generate // routes. Place it on the connected machine by ID because earlier tests rename that machine. - caddyDeployment, err := cli.NewCaddyDeployment("", "", api.Placement{ - Machines: []string{c.Machines[0].ID}, + caddyDeployment, err := cli.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{ + Placement: api.Placement{Machines: []string{c.Machines[0].ID}}, }) require.NoError(t, err) monitorPeriod := 5 * time.Second @@ -513,7 +513,7 @@ func TestMachineOperations(t *testing.T) { // The Caddyfile contains both upstream IPs before the machine removal. require.Eventually(t, func() bool { - cfg, err := cli.Caddy.GetConfig(ctx, nil) + cfg, err := cli.Caddy.Config(ctx, client.CaddyConfigOptions{}) if err != nil { return false } @@ -528,7 +528,7 @@ func TestMachineOperations(t *testing.T) { // so the Caddy controller regenerates a Caddyfile without that upstream while keeping the // upstreams for the still-running containers. require.Eventually(t, func() bool { - cfg, err := cli.Caddy.GetConfig(ctx, nil) + cfg, err := cli.Caddy.Config(ctx, client.CaddyConfigOptions{}) if err != nil { return false } diff --git a/test/e2e/service_test.go b/test/e2e/service_test.go index 03b07647..1664d363 100644 --- a/test/e2e/service_test.go +++ b/test/e2e/service_test.go @@ -17,7 +17,6 @@ import ( "github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/volume" "github.com/docker/go-units" - "github.com/psviderski/uncloud/api/pb" "github.com/psviderski/uncloud/internal/machine/metrics" "github.com/psviderski/uncloud/internal/machine/network" "github.com/psviderski/uncloud/internal/secret" @@ -316,7 +315,7 @@ func TestDeployment(t *testing.T) { } }) - deployment, err := cli.NewCaddyDeployment("", "", api.Placement{}) + deployment, err := cli.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{}) require.NoError(t, err) deployment.Spec.UpdateConfig.MonitorPeriod = &caddyMonitorPeriod @@ -331,7 +330,7 @@ func TestDeployment(t *testing.T) { ctr := svc.Containers[0].Container assert.Regexp(t, `^caddy:2\.\d+\.\d+$`, ctr.Config.Image) - config, err := cli.Caddy.GetConfig(ctx, nil) + config, err := cli.Caddy.Config(ctx, client.CaddyConfigOptions{}) require.NoError(t, err) assert.Contains(t, config.Caddyfile, "# Caddyfile autogenerated by Uncloud") @@ -347,8 +346,8 @@ func TestDeployment(t *testing.T) { }) // Deploy to machine #0. - deployment, err := cli.NewCaddyDeployment("", "", api.Placement{ - Machines: []string{c.Machines[0].Name}, + deployment, err := cli.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{ + Placement: api.Placement{Machines: []string{c.Machines[0].Name}}, }) require.NoError(t, err) deployment.Spec.UpdateConfig.MonitorPeriod = &caddyMonitorPeriod @@ -366,7 +365,7 @@ func TestDeployment(t *testing.T) { // initialContainerID := svc.Containers[0].Container.ID // Deploy to all machines without a placement constraint. - deployment, err = cli.NewCaddyDeployment(image, "", api.Placement{}) + deployment, err = cli.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{Image: image}) require.NoError(t, err) deployment.Spec.UpdateConfig.MonitorPeriod = &caddyMonitorPeriod @@ -401,7 +400,7 @@ func TestDeployment(t *testing.T) { // Without a Caddy container, the controller has no global config to pair with application configs. Keep any // previously saved Caddyfile unchanged rather than publishing a new one without Caddy's global settings. - savedBefore, savedBeforeErr := cli.Caddy.GetConfig(ctx, nil) + savedBefore, savedBeforeErr := cli.Caddy.Config(ctx, client.CaddyConfigOptions{}) if savedBeforeErr != nil { require.Equal(t, codes.NotFound, status.Code(savedBeforeErr)) } @@ -432,7 +431,7 @@ func TestDeployment(t *testing.T) { assertServiceMatchesSpec(t, svc, spec) require.Never(t, func() bool { - current, currentErr := cli.Caddy.GetConfig(ctx, nil) + current, currentErr := cli.Caddy.Config(ctx, client.CaddyConfigOptions{}) if savedBeforeErr != nil { return currentErr == nil } @@ -447,7 +446,7 @@ func TestDeployment(t *testing.T) { myapp.example.com { reverse_proxy 1.2.3.4:8000 }` - caddyDeployment, err := cli.NewCaddyDeployment("", caddyCaddyfile, api.Placement{}) + caddyDeployment, err := cli.Caddy.NewDeployment(ctx, client.CaddyDeploymentOptions{Config: caddyCaddyfile}) require.NoError(t, err) caddyDeployment.Spec.UpdateConfig.MonitorPeriod = &caddyMonitorPeriod @@ -459,9 +458,9 @@ myapp.example.com { assertServiceMatchesSpec(t, caddySvc, caddyDeployment.Spec) // Wait for the Caddyfile to be regenerated with both custom configs. - var config *pb.GetCaddyConfigResponse + var config api.CaddyConfig require.Eventually(t, func() bool { - config, err = cli.Caddy.GetConfig(ctx, nil) + config, err = cli.Caddy.Config(ctx, client.CaddyConfigOptions{}) if err != nil { return false } @@ -526,7 +525,7 @@ myapp.example.com { time.Sleep(2 * time.Second) // Check that the Caddy config hasn't changed. - newConfig, err := cli.Caddy.GetConfig(ctx, nil) + newConfig, err := cli.Caddy.Config(ctx, client.CaddyConfigOptions{}) require.NoError(t, err) // Compare stable parts of the Caddyfile only (skip autogenerated comment with timestamp).