zeroconf 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7f58175fb721fb08fa2a5abb36b0b2165d9b38592bb7ff66bd3a3f4c303ed2f9
4
- data.tar.gz: afd32fb72b31c90e84aaea02db2f81e603b872f8f81c1f6f147d15c13ce3713e
3
+ metadata.gz: 7e5fe766c828fdaf41bdacf9ecce12d863c4a636f7c2292ec3ca8aef89413950
4
+ data.tar.gz: 51786741d2dbc2dffb92a1b4ec25b31a994db81aa1d099dc6f530dded84964e7
5
5
  SHA512:
6
- metadata.gz: 2e4faed4b18775a5fec2faf0b4064556356699b049c6ca1d6d2d4c03b685cd8702e3ba8591d098799d3ec6ee54efdb9aee1f0e5ea68578b92c1df7ea8fbbba7b
7
- data.tar.gz: 210e338f7d21bbe98df962e4785e0716e2364e538fb44c1029217465fca0fe6ea2f414986d4bb7ce0f3588efd5bfbbd8caeb140ef5f1028b5e75b58648de57e6
6
+ metadata.gz: 89974e69dce75b540f73e70313944bea011fcfe00bd8519af0cba02cb27e823600325d117efabe2febaac05d8d782edf2ecd5014143e35bfa8a31f27019314bc
7
+ data.tar.gz: e4f99e2f61956d457d2ae10fdb4969e682f86cb959a9bd7f4937c389a160c07c4a8e9f5f4ca4d5dab925390ed3ac80909b3603698c80629b70e83072ef51ea62
@@ -4,12 +4,12 @@ on: [push, pull_request]
4
4
 
5
5
  jobs:
6
6
  test:
7
- runs-on: ${{ matrix.os }}-latest
7
+ runs-on: ${{ matrix.os }}
8
8
 
9
9
  strategy:
10
10
  fail-fast: false
11
11
  matrix:
12
- os: [ubuntu, macos]
12
+ os: [ubuntu-latest, macos-14]
13
13
  ruby: [ head, 3.2 ]
14
14
 
15
15
  steps:
@@ -0,0 +1,52 @@
1
+ name: Publish gem to rubygems.org
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ push:
13
+ if: github.repository == 'tenderlove/zeroconf'
14
+ runs-on: ubuntu-latest
15
+
16
+ environment:
17
+ name: rubygems.org
18
+ url: https://rubygems.org/gems/zeroconf
19
+
20
+ permissions:
21
+ contents: write
22
+ id-token: write
23
+
24
+ strategy:
25
+ matrix:
26
+ ruby: ["ruby"]
27
+
28
+ steps:
29
+ - name: Harden Runner
30
+ uses: step-security/harden-runner@v2
31
+ with:
32
+ egress-policy: audit
33
+
34
+ - uses: actions/checkout@v4
35
+
36
+ - name: Set up Ruby
37
+ uses: ruby/setup-ruby@v1
38
+ with:
39
+ ruby-version: ${{ matrix.ruby }}
40
+
41
+ - name: Install dependencies
42
+ run: bundle install --jobs 4 --retry 3
43
+
44
+ - name: Publish to RubyGems
45
+ uses: rubygems/release-gem@v1
46
+
47
+ - name: Create GitHub release
48
+ run: |
49
+ tag_name="$(git describe --tags --abbrev=0)"
50
+ gh release create "${tag_name}" --verify-tag --generate-notes
51
+ env:
52
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
data/Gemfile CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  source "https://rubygems.org"
2
4
 
3
5
  gemspec
data/Rakefile CHANGED
@@ -1,9 +1,11 @@
1
+ require "bundler"
1
2
  require "bundler/gem_tasks"
2
3
  require "rake/testtask"
3
4
 
5
+ Bundler::GemHelper.install_tasks
6
+
4
7
  Rake::TestTask.new do |t|
5
8
  t.libs << "test"
6
9
  t.test_files = FileList['test/*_test.rb']
7
- t.verbose = true
8
10
  t.warning = true
9
11
  end
@@ -7,9 +7,10 @@ module ZeroConf
7
7
  include Utils
8
8
 
9
9
  attr_reader :service, :service_port, :hostname, :service_interfaces,
10
- :service_name, :qualified_host, :text, :abort_on_malformed_requests
10
+ :service_name, :qualified_host, :text, :abort_on_malformed_requests,
11
+ :subtypes
11
12
 
12
- def initialize service, service_port, hostname = Socket.gethostname, service_interfaces: ZeroConf.service_interfaces, instance_name: nil, text: [""], abort_on_malformed_requests: false, started_callback: nil
13
+ def initialize service, service_port, hostname = Socket.gethostname, service_interfaces: ZeroConf.service_interfaces, instance_name: nil, text: [""], subtypes: [], abort_on_malformed_requests: false, started_callback: nil
13
14
  @service = service
14
15
  @service_port = service_port
15
16
 
@@ -19,13 +20,14 @@ module ZeroConf
19
20
 
20
21
  instance_name ||= @hostname
21
22
  if instance_name.include?(".")
22
- raise ArgumentError, "instance_name must not contain dots (is #{instance_name.inspect})"
23
+ raise ArgumentError, "instance_name must not contain dots (is #{instance_name.inspect})"
23
24
  end
24
25
 
25
26
  @service_name = "#{instance_name}.#{@service}"
26
27
  @qualified_host = "#{@hostname}.local."
27
28
  @started_callback = started_callback
28
29
  @text = text
30
+ @subtypes = subtypes.map { |st| "#{st}._sub.#{@service}" }
29
31
  @started = false
30
32
  @rd, @wr = IO.pipe
31
33
  end
@@ -61,6 +63,11 @@ module ZeroConf
61
63
  60,
62
64
  Resolv::DNS::Resource::IN::PTR.new(Resolv::DNS::Name.create(service_name))
63
65
 
66
+ subtypes.each do |st|
67
+ msg.add_answer st, 60,
68
+ Resolv::DNS::Resource::IN::PTR.new(Resolv::DNS::Name.create(service_name))
69
+ end
70
+
64
71
  msg
65
72
  end
66
73
 
@@ -93,6 +100,11 @@ module ZeroConf
93
100
  0,
94
101
  Resolv::DNS::Resource::IN::PTR.new(Resolv::DNS::Name.create(service_name))
95
102
 
103
+ subtypes.each do |st|
104
+ msg.add_answer st, 0,
105
+ Resolv::DNS::Resource::IN::PTR.new(Resolv::DNS::Name.create(service_name))
106
+ end
107
+
96
108
  msg
97
109
  end
98
110
 
@@ -174,6 +186,12 @@ module ZeroConf
174
186
  else
175
187
  name_answer_multicast
176
188
  end
189
+ when *subtypes
190
+ if unicast
191
+ subtype_unicast_answer qn
192
+ else
193
+ subtype_multicast_answer qn
194
+ end
177
195
  else
178
196
  #p [:QUERY2, type, type::ClassValue, name]
179
197
  end
@@ -313,6 +331,72 @@ module ZeroConf
313
331
  msg
314
332
  end
315
333
 
334
+ def subtype_multicast_answer subtype
335
+ msg = Resolv::DNS::Message.new(0)
336
+ msg.qr = 1
337
+ msg.aa = 1
338
+
339
+ msg.add_additional service_name, 60, Resolv::DNS::Resource::IN::SRV.new(0, 0, service_port, qualified_host)
340
+
341
+ service_interfaces.each do |iface|
342
+ if iface.addr.ipv4?
343
+ msg.add_additional qualified_host,
344
+ 60,
345
+ Resolv::DNS::Resource::IN::A.new(iface.addr.ip_address)
346
+ else
347
+ msg.add_additional qualified_host,
348
+ 60,
349
+ Resolv::DNS::Resource::IN::AAAA.new(iface.addr.ip_address)
350
+ end
351
+ end
352
+
353
+ if @text
354
+ msg.add_additional service_name,
355
+ 60,
356
+ Resolv::DNS::Resource::IN::TXT.new(*@text)
357
+ end
358
+
359
+ msg.add_answer subtype,
360
+ 60,
361
+ Resolv::DNS::Resource::IN::PTR.new(Resolv::DNS::Name.create(service_name))
362
+
363
+ msg
364
+ end
365
+
366
+ def subtype_unicast_answer subtype
367
+ msg = Resolv::DNS::Message.new(0)
368
+ msg.qr = 1
369
+ msg.aa = 1
370
+
371
+ msg.add_additional service_name, 10, Resolv::DNS::Resource::IN::SRV.new(0, 0, service_port, qualified_host)
372
+
373
+ service_interfaces.each do |iface|
374
+ if iface.addr.ipv4?
375
+ msg.add_additional qualified_host,
376
+ 10,
377
+ Resolv::DNS::Resource::IN::A.new(iface.addr.ip_address)
378
+ else
379
+ msg.add_additional qualified_host,
380
+ 10,
381
+ Resolv::DNS::Resource::IN::AAAA.new(iface.addr.ip_address)
382
+ end
383
+ end
384
+
385
+ if @text
386
+ msg.add_additional service_name,
387
+ 10,
388
+ Resolv::DNS::Resource::IN::TXT.new(*@text)
389
+ end
390
+
391
+ msg.add_answer subtype,
392
+ 10,
393
+ Resolv::DNS::Resource::IN::PTR.new(Resolv::DNS::Name.create(service_name))
394
+
395
+ msg.add_question subtype, PTR
396
+
397
+ msg
398
+ end
399
+
316
400
  def service_instance_multicast_answer
317
401
  msg = Resolv::DNS::Message.new(0)
318
402
  msg.qr = 1
@@ -23,18 +23,22 @@ module ZeroConf
23
23
  ::Resolv::DNS::Resource::ClassHash[[TypeValue, ClassValue]] = self # :nodoc:
24
24
  end
25
25
 
26
+ # ZeroConf::A and ZeroConf::SRV are referenced by name when constructing
27
+ # outgoing queries (see ZeroConf::Resolver). They share the numeric
28
+ # ClassValue (0x8001) with the MDNS::Announce::IN equivalents below, so
29
+ # only one of the two should populate Resolv's ClassHash to avoid
30
+ # "already initialized constant" warnings. The MDNS::Announce::IN side
31
+ # owns the registration.
26
32
  class A < Resolv::DNS::Resource::IN::A
27
33
  MDNS_UNICAST_RESPONSE = 0x8000
28
34
 
29
35
  ClassValue = Resolv::DNS::Resource::IN::ClassValue | MDNS_UNICAST_RESPONSE
30
- ClassHash[[TypeValue, ClassValue]] = self # :nodoc:
31
36
  end
32
37
 
33
38
  class SRV < Resolv::DNS::Resource::IN::SRV
34
39
  MDNS_UNICAST_RESPONSE = 0x8000
35
40
 
36
41
  ClassValue = Resolv::DNS::Resource::IN::ClassValue | MDNS_UNICAST_RESPONSE
37
- ClassHash[[TypeValue, ClassValue]] = self # :nodoc:
38
42
  end
39
43
 
40
44
  module MDNS
@@ -1,3 +1,3 @@
1
1
  module ZeroConf
2
- VERSION = "1.2.0"
2
+ VERSION = "1.3.1"
3
3
  end
data/lib/zeroconf.rb CHANGED
@@ -46,7 +46,7 @@ module ZeroConf
46
46
  port = nil
47
47
  ipv4 = []
48
48
  ipv6 = []
49
- r.additional.each { |name, ttl, data|
49
+ (r.answer + r.additional).each { |name, ttl, data|
50
50
  case data
51
51
  when Resolv::DNS::Resource::IN::SRV
52
52
  host = data.target.to_s
data/test/client_test.rb CHANGED
@@ -1,6 +1,45 @@
1
1
  require "helper"
2
2
 
3
3
  module ZeroConf
4
+ # Mimics responders like Nanoleaf which place SRV in the answer section of
5
+ # the PTR response instead of in the additional section. See
6
+ # https://github.com/tenderlove/zeroconf/issues/10
7
+ class NanoleafService < Service
8
+ private
9
+
10
+ def service_unicast_answer
11
+ msg = Resolv::DNS::Message.new(0)
12
+ msg.qr = 1
13
+ msg.aa = 1
14
+
15
+ # SRV goes in ANSWER (Nanoleaf-style), not additional
16
+ msg.add_answer service_name, 10,
17
+ Resolv::DNS::Resource::IN::SRV.new(0, 0, service_port, qualified_host)
18
+
19
+ service_interfaces.each do |iface|
20
+ if iface.addr.ipv4?
21
+ msg.add_additional qualified_host, 10,
22
+ Resolv::DNS::Resource::IN::A.new(iface.addr.ip_address)
23
+ else
24
+ msg.add_additional qualified_host, 10,
25
+ Resolv::DNS::Resource::IN::AAAA.new(iface.addr.ip_address)
26
+ end
27
+ end
28
+
29
+ if @text
30
+ msg.add_additional service_name, 10,
31
+ Resolv::DNS::Resource::IN::TXT.new(*@text)
32
+ end
33
+
34
+ msg.add_answer service, 10,
35
+ Resolv::DNS::Resource::IN::PTR.new(Resolv::DNS::Name.create(service_name))
36
+
37
+ msg.add_question service, PTR
38
+
39
+ msg
40
+ end
41
+ end
42
+
4
43
  class ClientTest < Test
5
44
  attr_reader :iface
6
45
 
@@ -9,6 +48,34 @@ module ZeroConf
9
48
  @iface = ZeroConf.interfaces.find_all { |x| x.addr.ipv4? }.first
10
49
  end
11
50
 
51
+ # Regression test for https://github.com/tenderlove/zeroconf/issues/10
52
+ # If a responder places SRV in the answer section (rather than additional),
53
+ # find_addrinfos must still return a usable [host, Addrinfo] tuple instead
54
+ # of crashing with "no implicit conversion from nil to integer".
55
+ def test_find_addrinfos_with_srv_in_answer
56
+ latch = Queue.new
57
+ s = NanoleafService.new SERVICE + ".",
58
+ 42424,
59
+ HOST_NAME,
60
+ service_interfaces: [iface],
61
+ text: ["test=1", "other=value"],
62
+ started_callback: -> { latch << :start }
63
+ runner = Thread.new { s.start }
64
+ latch.pop
65
+
66
+ addrinfos = ZeroConf.find_addrinfos(SERVICE, timeout: 1)
67
+
68
+ s.stop
69
+ runner.join
70
+
71
+ ours = addrinfos.find { |host, _| host == "#{HOST_NAME}.local" }
72
+ assert ours, "expected to find #{HOST_NAME}.local in #{addrinfos.inspect}"
73
+ host, addr = ours
74
+ assert_equal "#{HOST_NAME}.local", host
75
+ assert_equal 42424, addr.ip_port
76
+ assert_equal iface.addr.ip_address, addr.ip_address
77
+ end
78
+
12
79
  def test_resolve
13
80
  latch = Queue.new
14
81
  s = make_server iface, "coolhostname", started_callback: -> { latch << :start }
data/test/helper.rb CHANGED
@@ -47,6 +47,15 @@ module ZeroConf
47
47
  **opts
48
48
  end
49
49
 
50
+ def make_subtype_server iface, host = HOST_NAME, **opts
51
+ Service.new SERVICE + ".",
52
+ 42424,
53
+ host,
54
+ service_interfaces: [iface], text: ["test=1", "other=value"],
55
+ subtypes: ["_universal"],
56
+ **opts
57
+ end
58
+
50
59
  def make_listener rd, q, started_callback: nil
51
60
  Thread.new do
52
61
  sock = open_ipv4 Addrinfo.new(Socket.sockaddr_in(Resolv::MDNS::Port, Socket::INADDR_ANY)), Resolv::MDNS::Port
data/test/service_test.rb CHANGED
@@ -440,6 +440,132 @@ module ZeroConf
440
440
  assert_equal expected, res
441
441
  end
442
442
 
443
+ def test_subtypes_expanded
444
+ s = Service.new "_ipp._tcp.local.", 631, "printer",
445
+ service_interfaces: [iface],
446
+ subtypes: ["_universal", "_print"]
447
+
448
+ assert_equal ["_universal._sub._ipp._tcp.local.", "_print._sub._ipp._tcp.local."], s.subtypes
449
+ end
450
+
451
+ def test_subtypes_default_empty
452
+ s = make_server iface
453
+ assert_equal [], s.subtypes
454
+ end
455
+
456
+ def test_announcement_includes_subtype_ptrs
457
+ s = Service.new "_test-mdns._tcp.local.", 42424,
458
+ "tc-lan-adapter",
459
+ service_interfaces: [iface],
460
+ subtypes: ["_universal"]
461
+
462
+ ann = s.announcement
463
+ subtype_ptrs = ann.answer.select { |name, ttl, data|
464
+ name.to_s == "_universal._sub._test-mdns._tcp.local"
465
+ }
466
+
467
+ assert_equal 1, subtype_ptrs.length
468
+ assert_equal s.service_name, subtype_ptrs.first.last.name.to_s + "."
469
+ end
470
+
471
+ def test_disconnect_includes_subtype_ptrs
472
+ s = Service.new "_test-mdns._tcp.local.", 42424,
473
+ "tc-lan-adapter",
474
+ service_interfaces: [iface],
475
+ subtypes: ["_universal"]
476
+
477
+ msg = s.disconnect_msg
478
+ subtype_ptrs = msg.answer.select { |name, ttl, data|
479
+ name.to_s == "_universal._sub._test-mdns._tcp.local"
480
+ }
481
+
482
+ assert_equal 1, subtype_ptrs.length
483
+ assert_equal 0, subtype_ptrs.first[1] # TTL 0 for disconnect
484
+ end
485
+
486
+ def test_subtype_unicast_answer
487
+ latch = Queue.new
488
+ s = make_subtype_server iface, started_callback: -> { latch << :start }
489
+ runner = Thread.new { s.start }
490
+ latch.pop
491
+
492
+ subtype = "_universal._sub._test-mdns._tcp.local."
493
+ query = Resolv::DNS::Message.new 0
494
+ query.add_question subtype, PTR
495
+
496
+ sock = open_ipv4 iface.addr, 0
497
+ multicast_send sock, query.encode
498
+ res = Resolv::DNS::Message.decode read_with_timeout(sock).first
499
+ s.stop
500
+ runner.join
501
+
502
+ expected = Resolv::DNS::Message.new(0)
503
+ expected.qr = 1
504
+ expected.aa = 1
505
+
506
+ expected.add_additional s.service_name, 10, Resolv::DNS::Resource::IN::SRV.new(0, 0, s.service_port, s.qualified_host)
507
+
508
+ expected.add_additional s.qualified_host,
509
+ 10,
510
+ Resolv::DNS::Resource::IN::A.new(iface.addr.ip_address)
511
+
512
+ expected.add_additional s.service_name,
513
+ 10,
514
+ Resolv::DNS::Resource::IN::TXT.new(*s.text)
515
+
516
+ expected.add_answer subtype,
517
+ 10,
518
+ Resolv::DNS::Resource::IN::PTR.new(Resolv::DNS::Name.create(s.service_name))
519
+
520
+ expected.add_question subtype, PTR
521
+
522
+ assert_equal expected, res
523
+ end
524
+
525
+ def test_subtype_multicast_answer
526
+ q = Thread::Queue.new
527
+ rd, wr = IO.pipe
528
+
529
+ latch = Queue.new
530
+ listen = make_listener rd, q, started_callback: -> { latch << :start }
531
+ s = make_subtype_server iface, started_callback: -> { latch << :start }
532
+ server = Thread.new { s.start }
533
+ latch.pop
534
+ latch.pop
535
+
536
+ subtype = "_universal._sub._test-mdns._tcp.local."
537
+ query = Resolv::DNS::Message.new 0
538
+ query.add_question subtype, Resolv::DNS::Resource::IN::PTR
539
+ sock = open_ipv4 iface.addr, 0
540
+ multicast_send sock, query.encode
541
+
542
+ subtype_name = Resolv::DNS::Name.create subtype
543
+ service_name = Resolv::DNS::Name.create s.service_name
544
+
545
+ while res = q.pop
546
+ if res.answer.find { |name, ttl, data| name == subtype_name && data.name == service_name }
547
+ wr.write "x"
548
+ break
549
+ end
550
+ end
551
+
552
+ listen.join
553
+ s.stop
554
+ server.join
555
+
556
+ # Verify the response contains the subtype PTR
557
+ subtype_ptr = res.answer.find { |name, ttl, data|
558
+ name == subtype_name && data.name == service_name
559
+ }
560
+ assert subtype_ptr, "expected subtype PTR in answer"
561
+
562
+ # Verify it has SRV in additional
563
+ srv = res.additional.find { |_, _, data|
564
+ ZeroConf::MDNS::Announce::IN::SRV == data.class
565
+ }
566
+ assert srv, "expected SRV in additional"
567
+ end
568
+
443
569
  def test_raise_on_malformed_requests
444
570
  latch = Queue.new
445
571
  s = make_server iface, abort_on_malformed_requests: true, started_callback: -> { latch << :start }
data/zeroconf.gemspec CHANGED
@@ -12,7 +12,7 @@ Gem::Specification.new do |s|
12
12
  s.homepage = "https://github.com/tenderlove/zeroconf"
13
13
  s.license = "Apache-2.0"
14
14
 
15
- s.add_dependency("resolv", "~> 0.3.0")
15
+ s.add_dependency("resolv", ">= 0.7.1")
16
16
  s.add_development_dependency("rake", "~> 13.0")
17
17
  s.add_development_dependency("minitest", "~> 5.20")
18
18
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zeroconf
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aaron Patterson
@@ -13,16 +13,16 @@ dependencies:
13
13
  name: resolv
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
16
+ - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 0.3.0
18
+ version: 0.7.1
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
- - - "~>"
23
+ - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 0.3.0
25
+ version: 0.7.1
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: rake
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -58,6 +58,7 @@ extensions: []
58
58
  extra_rdoc_files: []
59
59
  files:
60
60
  - ".github/workflows/ci.yml"
61
+ - ".github/workflows/release.yml"
61
62
  - CODE_OF_CONDUCT.md
62
63
  - Gemfile
63
64
  - LICENSE
@@ -93,7 +94,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
93
94
  - !ruby/object:Gem::Version
94
95
  version: '0'
95
96
  requirements: []
96
- rubygems_version: 3.7.0.dev
97
+ rubygems_version: 4.0.10
97
98
  specification_version: 4
98
99
  summary: Multicast DNS client and server
99
100
  test_files: