master
go 567 lines 16.7 KB
Raw
1 package name
2
3 import (
4 "bytes"
5 "encoding/hex"
6 "errors"
7 "fmt"
8 "io"
9 "strings"
10 "text/tabwriter"
11 "time"
12
13 "github.com/ipfs/boxo/ipns"
14 ipns_pb "github.com/ipfs/boxo/ipns/pb"
15 cmds "github.com/ipfs/go-ipfs-cmds"
16 "github.com/ipfs/kubo/core/commands/cmdenv"
17 "github.com/ipfs/kubo/core/coreiface/options"
18 "google.golang.org/protobuf/proto"
19 )
20
21 type IpnsEntry struct {
22 Name string
23 Value string
24 }
25
26 var NameCmd = &cmds.Command{
27 Helptext: cmds.HelpText{
28 Tagline: "Publish and resolve IPNS names.",
29 ShortDescription: `
30 IPNS is a PKI namespace, where names are the hashes of public keys, and
31 the private key enables publishing new (signed) values. In both publish
32 and resolve, the default name used is the node's own PeerID,
33 which is the hash of its public key.
34 `,
35 LongDescription: `
36 IPNS is a PKI namespace, where names are the hashes of public keys, and
37 the private key enables publishing new (signed) values. In both publish
38 and resolve, the default name used is the node's own PeerID,
39 which is the hash of its public key.
40
41 You can use the 'ipfs key' commands to list and generate more names and their
42 respective keys.
43
44 Examples:
45
46 Publish an <ipfs-path> with your default name:
47
48 > ipfs name publish /ipfs/bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4
49 Published to k51qzi5uqu5dgklc20hksmmzhoy5lfrn5xcnryq6xp4r50b5yc0vnivpywfu9p: /ipfs/bafk...
50
51 Publish an <ipfs-path> with another name, added by an 'ipfs key' command:
52
53 > ipfs key gen --type=ed25519 mykey
54 k51qzi5uqu5dlz49qkb657myg6f1buu6rauv8c6b489a9i1e4dkt7a3yo9j2wr
55 > ipfs name publish --key=mykey /ipfs/bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4
56 Published to k51qzi5uqu5dlz49qkb657myg6f1buu6rauv8c6b489a9i1e4dkt7a3yo9j2wr: /ipfs/bafk...
57
58 Resolve the value of your name:
59
60 > ipfs name resolve
61 /ipfs/bafk...
62
63 Resolve the value of another name:
64
65 > ipfs name resolve k51qzi5uqu5dlz49qkb657myg6f1buu6rauv8c6b489a9i1e4dkt7a3yo9j2wr
66 /ipfs/bafk...
67
68 Resolve the value of a dnslink:
69
70 > ipfs name resolve specs.ipfs.tech
71 /ipfs/bafy...
72
73 `,
74 },
75
76 Subcommands: map[string]*cmds.Command{
77 "publish": PublishCmd,
78 "resolve": IpnsCmd,
79 "pubsub": IpnsPubsubCmd,
80 "inspect": IpnsInspectCmd,
81 "get": IpnsGetCmd,
82 "put": IpnsPutCmd,
83 },
84 }
85
86 type IpnsInspectValidation struct {
87 Valid bool
88 Reason string
89 Name string
90 }
91
92 // IpnsInspectEntry contains the deserialized values from an IPNS Entry:
93 // https://github.com/ipfs/specs/blob/main/ipns/IPNS.md#record-serialization-format
94 type IpnsInspectEntry struct {
95 Value string
96 ValidityType *ipns.ValidityType
97 Validity *time.Time
98 Sequence *uint64
99 TTL *time.Duration
100 }
101
102 type IpnsInspectResult struct {
103 Entry IpnsInspectEntry
104 PbSize int
105 SignatureType string
106 HexDump string
107 Validation *IpnsInspectValidation
108 }
109
110 var IpnsInspectCmd = &cmds.Command{
111 Status: cmds.Experimental,
112 Helptext: cmds.HelpText{
113 Tagline: "Inspects an IPNS Record",
114 ShortDescription: `
115 Prints values inside of IPNS Record protobuf and its DAG-CBOR Data field.
116 Passing --verify will verify signature against provided public key.
117 `,
118 LongDescription: `
119 Prints values inside of IPNS Record protobuf and its DAG-CBOR Data field.
120
121 The input can be a file or STDIN, the output can be JSON:
122
123 $ ipfs routing get "/ipns/$PEERID" > ipns_record
124 $ ipfs name inspect --enc=json < ipns_record
125
126 Values in PublicKey, SignatureV1 and SignatureV2 fields are raw bytes encoded
127 in Multibase. The Data field is DAG-CBOR represented as DAG-JSON.
128
129 Passing --verify will verify signature against provided public key.
130
131 `,
132 HTTP: &cmds.HTTPHelpText{
133 Description: "Request body should be `multipart/form-data` with the IPNS record bytes.",
134 },
135 },
136 Arguments: []cmds.Argument{
137 cmds.FileArg("record", true, false, "The IPNS record payload to be verified.").EnableStdin(),
138 },
139 Options: []cmds.Option{
140 cmds.StringOption("verify", "CID of the public IPNS key to validate against."),
141 cmds.BoolOption("dump", "Include a full hex dump of the raw Protobuf record.").WithDefault(true),
142 },
143 Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
144 file, err := cmdenv.GetFileArg(req.Files.Entries())
145 if err != nil {
146 return err
147 }
148 defer file.Close()
149
150 var b bytes.Buffer
151
152 _, err = io.Copy(&b, file)
153 if err != nil {
154 return err
155 }
156
157 rec, err := ipns.UnmarshalRecord(b.Bytes())
158 if err != nil {
159 return err
160 }
161
162 result := &IpnsInspectResult{
163 Entry: IpnsInspectEntry{},
164 }
165
166 // Best effort to get the fields. Show everything we can.
167 if v, err := rec.Value(); err == nil {
168 result.Entry.Value = v.String()
169 }
170
171 if v, err := rec.ValidityType(); err == nil {
172 result.Entry.ValidityType = &v
173 }
174
175 if v, err := rec.Validity(); err == nil {
176 result.Entry.Validity = &v
177 }
178
179 if v, err := rec.Sequence(); err == nil {
180 result.Entry.Sequence = &v
181 }
182
183 if v, err := rec.TTL(); err == nil {
184 result.Entry.TTL = &v
185 }
186
187 // Here we need the raw protobuf just to decide the version.
188 var pbRecord ipns_pb.IpnsRecord
189 err = proto.Unmarshal(b.Bytes(), &pbRecord)
190 if err != nil {
191 return err
192 }
193 if len(pbRecord.SignatureV1) != 0 || len(pbRecord.Value) != 0 {
194 result.SignatureType = "V1+V2"
195 } else if pbRecord.Data != nil {
196 result.SignatureType = "V2"
197 } else {
198 result.SignatureType = "Unknown"
199 }
200 result.PbSize = proto.Size(&pbRecord)
201
202 if verify, ok := req.Options["verify"].(string); ok {
203 name, err := ipns.NameFromString(verify)
204 if err != nil {
205 return err
206 }
207
208 result.Validation = &IpnsInspectValidation{
209 Name: name.String(),
210 }
211
212 err = ipns.ValidateWithName(rec, name)
213 if err == nil {
214 result.Validation.Valid = true
215 } else {
216 result.Validation.Reason = err.Error()
217 }
218 }
219
220 if dump, ok := req.Options["dump"].(bool); ok && dump {
221 result.HexDump = hex.Dump(b.Bytes())
222 }
223
224 return cmds.EmitOnce(res, result)
225 },
226 Type: IpnsInspectResult{},
227 Encoders: cmds.EncoderMap{
228 cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *IpnsInspectResult) error {
229 tw := tabwriter.NewWriter(w, 0, 0, 1, ' ', 0)
230 defer tw.Flush()
231
232 if out.Entry.Value != "" {
233 fmt.Fprintf(tw, "Value:\t%q\n", out.Entry.Value)
234 }
235
236 if out.Entry.ValidityType != nil {
237 fmt.Fprintf(tw, "Validity Type:\t%d\n", *out.Entry.ValidityType)
238 }
239
240 if out.Entry.Validity != nil {
241 fmt.Fprintf(tw, "Validity:\t%q\n", out.Entry.Validity.Format(time.RFC3339Nano))
242 }
243
244 if out.Entry.Sequence != nil {
245 fmt.Fprintf(tw, "Sequence:\t%d\n", *out.Entry.Sequence)
246 }
247
248 if out.Entry.TTL != nil {
249 fmt.Fprintf(tw, "TTL:\t%s\n", out.Entry.TTL.String())
250 }
251
252 fmt.Fprintf(tw, "Protobuf Size:\t%d\n", out.PbSize)
253 fmt.Fprintf(tw, "Signature Type:\t%s\n", out.SignatureType)
254
255 if out.Validation == nil {
256 tw.Flush()
257 fmt.Fprintf(w, "\nThis record was not verified. Pass '--verify k51...' to verify.\n")
258 } else {
259 tw.Flush()
260 fmt.Fprintf(w, "\nValidation results:\n")
261
262 fmt.Fprintf(tw, "\tValid:\t%v\n", out.Validation.Valid)
263 if out.Validation.Reason != "" {
264 fmt.Fprintf(tw, "\tReason:\t%s\n", out.Validation.Reason)
265 }
266 fmt.Fprintf(tw, "\tName:\t%s\n", out.Validation.Name)
267 }
268
269 if out.HexDump != "" {
270 tw.Flush()
271
272 fmt.Fprintf(w, "\nHex Dump:\n%s", out.HexDump)
273 }
274
275 return nil
276 }),
277 },
278 }
279
280 var IpnsGetCmd = &cmds.Command{
281 Status: cmds.Experimental,
282 Helptext: cmds.HelpText{
283 Tagline: "Retrieve a signed IPNS record.",
284 ShortDescription: `
285 Retrieves the signed IPNS record for a given name from the routing system.
286
287 The output is the raw IPNS record (protobuf) as defined in the IPNS spec:
288 https://specs.ipfs.tech/ipns/ipns-record/
289
290 The record can be inspected with 'ipfs name inspect':
291
292 ipfs name get <name> | ipfs name inspect
293
294 This is equivalent to 'ipfs routing get /ipns/<name>' but only accepts
295 IPNS names (not arbitrary routing keys).
296
297 Note: The routing system returns the "best" IPNS record it knows about.
298 For IPNS, "best" means the record with the highest sequence number.
299 If multiple records exist (e.g., after using 'ipfs name put'), this command
300 returns the one the routing system considers most current.
301 `,
302 HTTP: &cmds.HTTPHelpText{
303 ResponseContentType: "application/vnd.ipfs.ipns-record",
304 },
305 },
306 Arguments: []cmds.Argument{
307 cmds.StringArg("name", true, false, "The IPNS name to look up."),
308 },
309 Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
310 api, err := cmdenv.GetApi(env, req)
311 if err != nil {
312 return err
313 }
314
315 // Normalize the argument: accept both "k51..." and "/ipns/k51..."
316 name := req.Arguments[0]
317 if !strings.HasPrefix(name, "/ipns/") {
318 name = "/ipns/" + name
319 }
320
321 data, err := api.Routing().Get(req.Context, name)
322 if err != nil {
323 return err
324 }
325
326 res.SetEncodingType(cmds.OctetStream)
327 res.SetContentType("application/vnd.ipfs.ipns-record")
328 return res.Emit(bytes.NewReader(data))
329 },
330 }
331
332 const (
333 forceOptionName = "force"
334 putAllowOfflineOption = "allow-offline"
335 allowDelegatedOption = "allow-delegated"
336 putQuietOptionName = "quiet"
337 maxIPNSRecordSize = 10 << 10 // 10 KiB per IPNS spec
338 )
339
340 var errPutAllowOffline = errors.New("can't put while offline: pass `--allow-offline` to store locally or `--allow-delegated` if Ipns.DelegatedPublishers are set up")
341
342 var IpnsPutCmd = &cmds.Command{
343 Status: cmds.Experimental,
344 Helptext: cmds.HelpText{
345 Tagline: "Store a pre-signed IPNS record in the routing system.",
346 ShortDescription: `
347 Stores a pre-signed IPNS record in the routing system.
348
349 This command accepts a raw IPNS record (protobuf) as defined in the IPNS spec:
350 https://specs.ipfs.tech/ipns/ipns-record/
351
352 The record must be signed by the private key corresponding to the IPNS name.
353 Use 'ipfs name get' to retrieve records and 'ipfs name inspect' to examine.
354 `,
355 LongDescription: `
356 Stores a pre-signed IPNS record in the routing system.
357
358 This command accepts a raw IPNS record (protobuf) as defined in the IPNS spec:
359 https://specs.ipfs.tech/ipns/ipns-record/
360
361 The record must be signed by the private key corresponding to the IPNS name.
362 Use 'ipfs name get' to retrieve records and 'ipfs name inspect' to examine.
363
364 Use Cases:
365
366 - Re-publishing third-party records: store someone else's signed record
367 - Cross-node sync: import records exported from another node
368 - Backup/restore: export with 'name get', restore with 'name put'
369
370 Validation:
371
372 By default, the command validates that:
373
374 - The record is a valid IPNS record (protobuf)
375 - The record size is within 10 KiB limit
376 - The signature matches the provided IPNS name
377 - The record's sequence number is higher than any existing record
378 (identical records are allowed for republishing)
379
380 The --force flag skips this command's validation and passes the record
381 directly to the routing system. Note that --force only affects this command;
382 it does not control how the routing system handles the record. The routing
383 system may still reject invalid records or prefer records with higher sequence
384 numbers. Use --force primarily for testing (e.g., to observe how the routing
385 system reacts to incorrectly signed or malformed records).
386
387 Important: Even after a successful 'name put', a subsequent 'name get' may
388 return a different record if one with a higher sequence number exists.
389 This is expected IPNS behavior, not a bug.
390
391 Publishing Modes:
392
393 By default, IPNS records are published to both the DHT and any configured
394 HTTP delegated publishers. You can control this behavior with:
395
396 --allow-offline Store locally without requiring network connectivity
397 --allow-delegated Publish via HTTP delegated publishers only (no DHT)
398
399 Examples:
400
401 Export and re-import a record:
402
403 > ipfs name get k51... > record.bin
404 > ipfs name put k51... record.bin
405
406 Store a record received from someone else:
407
408 > ipfs name put k51... third-party-record.bin
409
410 Force store a record to test routing validation:
411
412 > ipfs name put --force k51... possibly-invalid-record.bin
413 `,
414 HTTP: &cmds.HTTPHelpText{
415 Description: "Request body should be `multipart/form-data` with the IPNS record bytes.",
416 },
417 },
418 Arguments: []cmds.Argument{
419 cmds.StringArg("name", true, false, "The IPNS name to store the record for (e.g., k51... or /ipns/k51...)."),
420 cmds.FileArg("record", true, false, "Path to file containing the signed IPNS record.").EnableStdin(),
421 },
422 Options: []cmds.Option{
423 cmds.BoolOption(forceOptionName, "f", "Skip validation (signature, sequence, size)."),
424 cmds.BoolOption(putAllowOfflineOption, "Store locally without broadcasting to the network."),
425 cmds.BoolOption(allowDelegatedOption, "Publish via HTTP delegated publishers only (no DHT)."),
426 cmds.BoolOption(putQuietOptionName, "q", "Write no output."),
427 },
428 Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
429 nd, err := cmdenv.GetNode(env)
430 if err != nil {
431 return err
432 }
433
434 api, err := cmdenv.GetApi(env, req)
435 if err != nil {
436 return err
437 }
438
439 // Parse options
440 force, _ := req.Options[forceOptionName].(bool)
441 allowOffline, _ := req.Options[putAllowOfflineOption].(bool)
442 allowDelegated, _ := req.Options[allowDelegatedOption].(bool)
443
444 // Validate flag combinations
445 if allowOffline && allowDelegated {
446 return errors.New("cannot use both --allow-offline and --allow-delegated flags")
447 }
448
449 // Handle different publishing modes
450 if allowDelegated {
451 // AllowDelegated mode: check if delegated publishers are configured
452 cfg, err := nd.Repo.Config()
453 if err != nil {
454 return fmt.Errorf("failed to read config: %w", err)
455 }
456 delegatedPublishers := cfg.DelegatedPublishersWithAutoConf()
457 if len(delegatedPublishers) == 0 {
458 return errors.New("no delegated publishers configured: add Ipns.DelegatedPublishers or use --allow-offline for local-only publishing")
459 }
460 // For allow-delegated mode, we proceed even if offline
461 // since we're using HTTP publishing via delegated publishers
462 }
463
464 // Parse the IPNS name argument
465 nameArg := req.Arguments[0]
466 if !strings.HasPrefix(nameArg, "/ipns/") {
467 nameArg = "/ipns/" + nameArg
468 }
469 // Extract the name part after /ipns/
470 namePart := strings.TrimPrefix(nameArg, "/ipns/")
471 name, err := ipns.NameFromString(namePart)
472 if err != nil {
473 return fmt.Errorf("invalid IPNS name: %w", err)
474 }
475
476 // Read raw record bytes from file/stdin
477 file, err := cmdenv.GetFileArg(req.Files.Entries())
478 if err != nil {
479 return err
480 }
481 defer file.Close()
482
483 // Read record data (limit to 1 MiB for memory safety)
484 data, err := io.ReadAll(io.LimitReader(file, 1<<20))
485 if err != nil {
486 return fmt.Errorf("failed to read record: %w", err)
487 }
488 if len(data) == 0 {
489 return errors.New("record is empty")
490 }
491
492 // Validate unless --force
493 if !force {
494 // Check size limit per IPNS spec
495 if len(data) > maxIPNSRecordSize {
496 return fmt.Errorf("record exceeds maximum size of %d bytes, use --force to skip size check", maxIPNSRecordSize)
497 }
498 rec, err := ipns.UnmarshalRecord(data)
499 if err != nil {
500 return fmt.Errorf("invalid IPNS record: %w", err)
501 }
502
503 // Validate signature against provided name
504 err = ipns.ValidateWithName(rec, name)
505 if err != nil {
506 return fmt.Errorf("record validation failed: %w", err)
507 }
508
509 // Check for sequence conflicts with existing record
510 existingData, err := api.Routing().Get(req.Context, nameArg)
511 if err == nil {
512 // Allow republishing the exact same record (common use case:
513 // get a third-party record and put it back to refresh DHT)
514 if !bytes.Equal(existingData, data) {
515 existingRec, parseErr := ipns.UnmarshalRecord(existingData)
516 if parseErr == nil {
517 existingSeq, seqErr := existingRec.Sequence()
518 newSeq, newSeqErr := rec.Sequence()
519 if seqErr == nil && newSeqErr == nil && existingSeq >= newSeq {
520 return fmt.Errorf("existing IPNS record has sequence %d >= new record sequence %d, use 'ipfs name put --force' to skip this check", existingSeq, newSeq)
521 }
522 }
523 }
524 }
525 // If Get fails (no existing record), that's fine - proceed with put
526 }
527
528 // Publish the original bytes as-is
529 // When allowDelegated is true, we set allowOffline to allow the operation
530 // even without DHT connectivity (delegated publishers use HTTP)
531 opts := []options.RoutingPutOption{
532 options.Routing.AllowOffline(allowOffline || allowDelegated),
533 }
534
535 err = api.Routing().Put(req.Context, nameArg, data, opts...)
536 if err != nil {
537 if err.Error() == "can't put while offline" {
538 return errPutAllowOffline
539 }
540 return err
541 }
542
543 // Extract value from the record for the response
544 value := ""
545 if rec, err := ipns.UnmarshalRecord(data); err == nil {
546 if v, err := rec.Value(); err == nil {
547 value = v.String()
548 }
549 }
550
551 return cmds.EmitOnce(res, &IpnsEntry{
552 Name: name.String(),
553 Value: value,
554 })
555 },
556 Encoders: cmds.EncoderMap{
557 cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, ie *IpnsEntry) error {
558 quiet, _ := req.Options[putQuietOptionName].(bool)
559 if quiet {
560 return nil
561 }
562 _, err := fmt.Fprintln(w, cmdenv.EscNonPrint(ie.Name))
563 return err
564 }),
565 },
566 Type: IpnsEntry{},
567 }