0x0-cli 1.0.0
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.
- package/bin/0x0.js +141 -0
- package/dist/web/assets/index-BrxSM83h.css +1 -0
- package/dist/web/assets/index-Ci89CtgQ.js +176 -0
- package/dist/web/index.html +15 -0
- package/package.json +37 -0
- package/src/commands/chat.js +204 -0
- package/src/commands/contact.js +63 -0
- package/src/commands/inbox.js +68 -0
- package/src/commands/init.js +31 -0
- package/src/commands/listen.js +74 -0
- package/src/commands/pin.js +114 -0
- package/src/commands/pipe.js +121 -0
- package/src/commands/qr.js +35 -0
- package/src/commands/read.js +48 -0
- package/src/commands/renew.js +28 -0
- package/src/commands/send.js +61 -0
- package/src/commands/web.js +37 -0
- package/src/commands/whoami.js +24 -0
- package/src/core/channel.js +10 -0
- package/src/core/identity.js +8 -0
- package/src/core/message.js +35 -0
- package/src/core/pin.js +10 -0
- package/src/core/swarm.js +12 -0
- package/src/core/uri.js +12 -0
- package/src/storage/contacts.js +72 -0
- package/src/storage/identity.js +30 -0
- package/src/storage/messages.js +48 -0
- package/src/storage/pins.js +71 -0
- package/src/web/api.js +375 -0
- package/src/web/server.js +52 -0
package/src/web/api.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto'
|
|
2
|
+
import Hyperswarm from 'hyperswarm'
|
|
3
|
+
import { channelSecret } from '../core/channel.js'
|
|
4
|
+
import { createMessage, createFileMessage, parseMessage } from '../core/message.js'
|
|
5
|
+
import { generatePin } from '../core/pin.js'
|
|
6
|
+
import * as identityStore from '../storage/identity.js'
|
|
7
|
+
import * as pinsStore from '../storage/pins.js'
|
|
8
|
+
import * as messagesStore from '../storage/messages.js'
|
|
9
|
+
import * as contactsStore from '../storage/contacts.js'
|
|
10
|
+
|
|
11
|
+
export function createApiHandler(ws) {
|
|
12
|
+
const connections = new Map() // pinId → { swarm, conn }
|
|
13
|
+
const contactConnections = new Map() // contactId → { swarm, conn }
|
|
14
|
+
const messageQueue = new Map() // pinId → [{ id, content, timestamp }]
|
|
15
|
+
|
|
16
|
+
function sendEvent(event, payload = {}) {
|
|
17
|
+
if (ws.readyState !== ws.OPEN) return
|
|
18
|
+
ws.send(JSON.stringify({ event, ...payload }))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 接続確立時に初期データを送信
|
|
22
|
+
const identity = identityStore.load()
|
|
23
|
+
if (identity) {
|
|
24
|
+
sendEvent('init', { data: {
|
|
25
|
+
number: identity.number,
|
|
26
|
+
inbox: buildInbox(),
|
|
27
|
+
contacts: buildContacts(),
|
|
28
|
+
prefs: { theme: 'dark' }
|
|
29
|
+
}})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
ws.on('message', async (raw) => {
|
|
33
|
+
let msg
|
|
34
|
+
try { msg = JSON.parse(raw.toString()) } catch { return }
|
|
35
|
+
await dispatch(msg)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
ws.on('close', async () => {
|
|
39
|
+
for (const { swarm } of connections.values()) {
|
|
40
|
+
await swarm.destroy().catch(() => {})
|
|
41
|
+
}
|
|
42
|
+
for (const { swarm } of contactConnections.values()) {
|
|
43
|
+
await swarm.destroy().catch(() => {})
|
|
44
|
+
}
|
|
45
|
+
connections.clear()
|
|
46
|
+
contactConnections.clear()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
async function dispatch(msg) {
|
|
50
|
+
const { cmd, pinId, content, label, expiry, key, value } = msg
|
|
51
|
+
|
|
52
|
+
switch (cmd) {
|
|
53
|
+
case 'inbox.list':
|
|
54
|
+
sendEvent('inbox.list', { data: buildInbox() })
|
|
55
|
+
break
|
|
56
|
+
|
|
57
|
+
case 'messages.list': {
|
|
58
|
+
const pin = pinsStore.findById(pinId)
|
|
59
|
+
if (!pin) return
|
|
60
|
+
const msgs = messagesStore.list(pin.value)
|
|
61
|
+
sendEvent('messages.list', { pinId, data: msgs })
|
|
62
|
+
// まだ接続していなければ開始
|
|
63
|
+
if (!connections.has(pinId)) await openConnection(pin)
|
|
64
|
+
break
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
case 'message.send': {
|
|
68
|
+
const pin = pinsStore.findById(pinId)
|
|
69
|
+
if (!pin) return
|
|
70
|
+
const localId = msg.localId || randomUUID()
|
|
71
|
+
const entry = connections.get(pinId)
|
|
72
|
+
if (!entry?.conn) {
|
|
73
|
+
const pending = messageQueue.get(pinId) || []
|
|
74
|
+
pending.push({ id: localId, content, timestamp: Date.now() })
|
|
75
|
+
messageQueue.set(pinId, pending)
|
|
76
|
+
sendEvent('message.queued', { pinId, localId })
|
|
77
|
+
break
|
|
78
|
+
}
|
|
79
|
+
entry.conn.write(createMessage(content))
|
|
80
|
+
const senderIdentity = identityStore.load()
|
|
81
|
+
messagesStore.append(pin.value, { from: senderIdentity.number, content, isMine: true })
|
|
82
|
+
sendEvent('message.sent', { pinId, localId, data: { content, isMine: true, timestamp: Date.now(), status: 'delivered' } })
|
|
83
|
+
break
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
case 'pin.create': {
|
|
87
|
+
const { expiry: exp = 'none', expiresAt = null } = parseExpiry(expiry)
|
|
88
|
+
const value = generatePin()
|
|
89
|
+
const pin = pinsStore.create({ value, label: label || '', expiry: exp, expiresAt })
|
|
90
|
+
sendEvent('pin.created', { data: pin })
|
|
91
|
+
sendEvent('inbox.list', { data: buildInbox() })
|
|
92
|
+
break
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
case 'pin.rotate': {
|
|
96
|
+
const pin = pinsStore.findById(pinId)
|
|
97
|
+
if (!pin) return
|
|
98
|
+
pinsStore.rotate(pin.id, generatePin())
|
|
99
|
+
await destroyConnection(pinId)
|
|
100
|
+
sendEvent('pin.rotated', { pinId, data: pinsStore.findById(pinId) })
|
|
101
|
+
sendEvent('inbox.list', { data: buildInbox() })
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
case 'pin.revoke': {
|
|
106
|
+
const pin = pinsStore.findById(pinId)
|
|
107
|
+
if (!pin) return
|
|
108
|
+
pinsStore.revoke(pin.id)
|
|
109
|
+
await destroyConnection(pinId)
|
|
110
|
+
sendEvent('pin.revoked', { pinId })
|
|
111
|
+
sendEvent('inbox.list', { data: buildInbox() })
|
|
112
|
+
break
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
case 'number.renew': {
|
|
116
|
+
const identity = identityStore.load()
|
|
117
|
+
if (!identity) return
|
|
118
|
+
const pins = pinsStore.getActive()
|
|
119
|
+
for (const p of pins) pinsStore.revoke(p.id)
|
|
120
|
+
for (const { swarm } of connections.values()) await swarm.destroy().catch(() => {})
|
|
121
|
+
connections.clear()
|
|
122
|
+
const { generateNumber } = await import('../core/identity.js')
|
|
123
|
+
const newNumber = generateNumber()
|
|
124
|
+
identityStore.save({ ...identity, number: newNumber, renewedAt: Date.now() })
|
|
125
|
+
sendEvent('number.renewed', { data: { number: newNumber } })
|
|
126
|
+
break
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case 'prefs.set':
|
|
130
|
+
// 将来: プリファレンス保存
|
|
131
|
+
break
|
|
132
|
+
|
|
133
|
+
case 'notify.register': {
|
|
134
|
+
const { token, platform } = msg
|
|
135
|
+
if (!token || !platform) break
|
|
136
|
+
const notifyIdentity = identityStore.load()
|
|
137
|
+
if (!notifyIdentity) break
|
|
138
|
+
await fetch('https://0x0-notification.tiidatech.workers.dev/register', {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
headers: { 'Content-Type': 'application/json' },
|
|
141
|
+
body: JSON.stringify({ number: notifyIdentity.number, token, platform })
|
|
142
|
+
}).catch(() => {})
|
|
143
|
+
sendEvent('notify.registered', {})
|
|
144
|
+
break
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
case 'notify.unregister': {
|
|
148
|
+
const unregIdentity = identityStore.load()
|
|
149
|
+
if (!unregIdentity) break
|
|
150
|
+
await fetch('https://0x0-notification.tiidatech.workers.dev/register', {
|
|
151
|
+
method: 'DELETE',
|
|
152
|
+
headers: { 'Content-Type': 'application/json' },
|
|
153
|
+
body: JSON.stringify({ number: unregIdentity.number })
|
|
154
|
+
}).catch(() => {})
|
|
155
|
+
sendEvent('notify.unregistered', {})
|
|
156
|
+
break
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
case 'file.send': {
|
|
160
|
+
const { filename, mimeType, dataBase64 } = msg
|
|
161
|
+
const filePin = pinsStore.findById(pinId)
|
|
162
|
+
if (!filePin) break
|
|
163
|
+
const fileEntry = connections.get(pinId)
|
|
164
|
+
if (!fileEntry?.conn) break
|
|
165
|
+
fileEntry.conn.write(createFileMessage(filename, mimeType, dataBase64))
|
|
166
|
+
const fileIdentity = identityStore.load()
|
|
167
|
+
messagesStore.append(filePin.value, {
|
|
168
|
+
from: fileIdentity.number, type: 'file', filename, mimeType, isMine: true
|
|
169
|
+
})
|
|
170
|
+
sendEvent('file.sent', { pinId, data: { filename, mimeType, isMine: true, timestamp: Date.now() } })
|
|
171
|
+
break
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
case 'chat.start': {
|
|
175
|
+
const { theirNumber, theirPin, label: cLabel } = msg
|
|
176
|
+
if (!theirNumber || !theirPin) return
|
|
177
|
+
const contact = contactsStore.create({ theirNumber, theirPin, label: cLabel || '' })
|
|
178
|
+
sendEvent('chat.started', { contactId: contact.id, data: contact })
|
|
179
|
+
sendEvent('contacts.list', { data: buildContacts() })
|
|
180
|
+
const cMsgs = messagesStore.list(contact.theirPin)
|
|
181
|
+
sendEvent('contact.messages.list', { contactId: contact.id, data: cMsgs })
|
|
182
|
+
if (!contactConnections.has(contact.id)) await openContactConnection(contact)
|
|
183
|
+
break
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
case 'contact.message.send': {
|
|
187
|
+
const { contactId, content: cContent } = msg
|
|
188
|
+
const contact = contactsStore.findById(contactId)
|
|
189
|
+
if (!contact) return
|
|
190
|
+
const cEntry = contactConnections.get(contactId)
|
|
191
|
+
if (!cEntry?.conn) return
|
|
192
|
+
cEntry.conn.write(createMessage(cContent))
|
|
193
|
+
const me = identityStore.load()
|
|
194
|
+
messagesStore.append(contact.theirPin, { from: me.number, content: cContent, isMine: true })
|
|
195
|
+
sendEvent('contact.message.sent', { contactId, data: { content: cContent, isMine: true, timestamp: Date.now() } })
|
|
196
|
+
// 相手に通知を送る(バックグラウンド、失敗は無視)
|
|
197
|
+
fetch('https://0x0-notification.tiidatech.workers.dev/notify', {
|
|
198
|
+
method: 'POST',
|
|
199
|
+
headers: { 'Content-Type': 'application/json' },
|
|
200
|
+
body: JSON.stringify({ recipientNumber: contact.theirNumber })
|
|
201
|
+
}).catch(() => {})
|
|
202
|
+
break
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
case 'contacts.list':
|
|
206
|
+
sendEvent('contacts.list', { data: buildContacts() })
|
|
207
|
+
break
|
|
208
|
+
|
|
209
|
+
case 'contact.label': {
|
|
210
|
+
const { contactId, label: newLabel } = msg
|
|
211
|
+
contactsStore.updateLabel(contactId, newLabel || '')
|
|
212
|
+
sendEvent('contacts.list', { data: buildContacts() })
|
|
213
|
+
break
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
case 'contact.remove': {
|
|
217
|
+
const { contactId: removeId } = msg
|
|
218
|
+
if (contactConnections.has(removeId)) {
|
|
219
|
+
const { swarm } = contactConnections.get(removeId)
|
|
220
|
+
await swarm.destroy().catch(() => {})
|
|
221
|
+
contactConnections.delete(removeId)
|
|
222
|
+
}
|
|
223
|
+
contactsStore.remove(removeId)
|
|
224
|
+
sendEvent('contact.removed', { contactId: removeId })
|
|
225
|
+
sendEvent('contacts.list', { data: buildContacts() })
|
|
226
|
+
break
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function openConnection(pin) {
|
|
232
|
+
const identity = identityStore.load()
|
|
233
|
+
if (!identity) return
|
|
234
|
+
|
|
235
|
+
const swarm = new Hyperswarm()
|
|
236
|
+
const topic = channelSecret(identity.number, pin.value)
|
|
237
|
+
connections.set(pin.id, { swarm, conn: null })
|
|
238
|
+
|
|
239
|
+
sendEvent('peer.status', { pinId: pin.id, status: 'connecting' })
|
|
240
|
+
|
|
241
|
+
swarm.join(topic, { server: true, client: true })
|
|
242
|
+
|
|
243
|
+
swarm.on('connection', (conn) => {
|
|
244
|
+
const entry = connections.get(pin.id)
|
|
245
|
+
if (entry) entry.conn = conn
|
|
246
|
+
|
|
247
|
+
sendEvent('peer.status', { pinId: pin.id, status: 'connected' })
|
|
248
|
+
flushMessageQueue(pin, conn)
|
|
249
|
+
|
|
250
|
+
conn.on('data', (data) => {
|
|
251
|
+
const msg = parseMessage(data)
|
|
252
|
+
if (!msg) return
|
|
253
|
+
|
|
254
|
+
if (msg.type === 'message') {
|
|
255
|
+
messagesStore.append(pin.value, {
|
|
256
|
+
from: 'peer', content: msg.content, isMine: false
|
|
257
|
+
})
|
|
258
|
+
sendEvent('message.received', {
|
|
259
|
+
pinId: pin.id,
|
|
260
|
+
data: { content: msg.content, isMine: false, timestamp: Date.now() }
|
|
261
|
+
})
|
|
262
|
+
} else if (msg.type === 'file') {
|
|
263
|
+
messagesStore.append(pin.value, {
|
|
264
|
+
from: 'peer', type: 'file', filename: msg.filename, mimeType: msg.mimeType, isMine: false
|
|
265
|
+
})
|
|
266
|
+
sendEvent('file.received', {
|
|
267
|
+
pinId: pin.id,
|
|
268
|
+
data: {
|
|
269
|
+
filename: msg.filename,
|
|
270
|
+
mimeType: msg.mimeType,
|
|
271
|
+
dataBase64: msg.data,
|
|
272
|
+
isMine: false,
|
|
273
|
+
timestamp: Date.now()
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
conn.on('close', () => {
|
|
280
|
+
const entry = connections.get(pin.id)
|
|
281
|
+
if (entry) entry.conn = null
|
|
282
|
+
sendEvent('peer.status', { pinId: pin.id, status: 'disconnected' })
|
|
283
|
+
})
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function destroyConnection(pinId) {
|
|
288
|
+
if (!connections.has(pinId)) return
|
|
289
|
+
const { swarm } = connections.get(pinId)
|
|
290
|
+
await swarm.destroy().catch(() => {})
|
|
291
|
+
connections.delete(pinId)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function flushMessageQueue(pin, conn) {
|
|
295
|
+
const pending = messageQueue.get(pin.id)
|
|
296
|
+
if (!pending || pending.length === 0) return
|
|
297
|
+
const senderIdentity = identityStore.load()
|
|
298
|
+
for (const queued of pending) {
|
|
299
|
+
conn.write(createMessage(queued.content))
|
|
300
|
+
if (senderIdentity) messagesStore.append(pin.value, { from: senderIdentity.number, content: queued.content, isMine: true })
|
|
301
|
+
sendEvent('message.delivered', { pinId: pin.id, localId: queued.id })
|
|
302
|
+
}
|
|
303
|
+
messageQueue.delete(pin.id)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function buildInbox() {
|
|
307
|
+
const pins = pinsStore.getActive()
|
|
308
|
+
return pins.map(pin => {
|
|
309
|
+
const msgs = messagesStore.list(pin.value)
|
|
310
|
+
const latest = msgs[msgs.length - 1] || null
|
|
311
|
+
const unread = msgs.filter(m => !m.isMine).length
|
|
312
|
+
return { ...pin, messageCount: msgs.length, unread, latest }
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function buildContacts() {
|
|
317
|
+
return contactsStore.loadAll().map(c => {
|
|
318
|
+
const msgs = messagesStore.list(c.theirPin)
|
|
319
|
+
const latest = msgs[msgs.length - 1] || null
|
|
320
|
+
const unread = msgs.filter(m => !m.isMine).length
|
|
321
|
+
return { ...c, messageCount: msgs.length, unread, latest }
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function openContactConnection(contact) {
|
|
326
|
+
const swarm = new Hyperswarm()
|
|
327
|
+
const topic = channelSecret(contact.theirNumber, contact.theirPin)
|
|
328
|
+
contactConnections.set(contact.id, { swarm, conn: null })
|
|
329
|
+
|
|
330
|
+
sendEvent('peer.status', { contactId: contact.id, status: 'connecting' })
|
|
331
|
+
|
|
332
|
+
swarm.join(topic, { server: false, client: true })
|
|
333
|
+
|
|
334
|
+
swarm.on('connection', (conn) => {
|
|
335
|
+
const entry = contactConnections.get(contact.id)
|
|
336
|
+
if (entry) entry.conn = conn
|
|
337
|
+
|
|
338
|
+
sendEvent('peer.status', { contactId: contact.id, status: 'connected' })
|
|
339
|
+
|
|
340
|
+
conn.on('data', (data) => {
|
|
341
|
+
const msg = parseMessage(data)
|
|
342
|
+
if (!msg || msg.type !== 'message') return
|
|
343
|
+
|
|
344
|
+
messagesStore.append(contact.theirPin, {
|
|
345
|
+
from: contact.theirNumber, content: msg.content, isMine: false
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
sendEvent('contact.message.received', {
|
|
349
|
+
contactId: contact.id,
|
|
350
|
+
data: { content: msg.content, isMine: false, timestamp: Date.now() }
|
|
351
|
+
})
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
conn.on('error', () => {})
|
|
355
|
+
|
|
356
|
+
conn.on('close', () => {
|
|
357
|
+
const entry = contactConnections.get(contact.id)
|
|
358
|
+
if (entry) entry.conn = null
|
|
359
|
+
sendEvent('peer.status', { contactId: contact.id, status: 'disconnected' })
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function parseExpiry(expires) {
|
|
366
|
+
if (!expires || expires === 'none') return { expiry: 'none', expiresAt: null }
|
|
367
|
+
const match = expires.match(/^(\d+)(h|d|w)$/)
|
|
368
|
+
if (!match) return { expiry: expires, expiresAt: null }
|
|
369
|
+
const num = parseInt(match[1])
|
|
370
|
+
const unit = match[2]
|
|
371
|
+
const ms = unit === 'h' ? num * 3_600_000
|
|
372
|
+
: unit === 'd' ? num * 86_400_000
|
|
373
|
+
: num * 7 * 86_400_000
|
|
374
|
+
return { expiry: expires, expiresAt: Date.now() + ms }
|
|
375
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import http from 'http'
|
|
2
|
+
import os from 'os'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import express from 'express'
|
|
6
|
+
import { WebSocketServer } from 'ws'
|
|
7
|
+
import { createApiHandler } from './api.js'
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
const DIST = path.resolve(__dirname, '../../dist/web')
|
|
11
|
+
|
|
12
|
+
export async function startWebServer(port = 3000) {
|
|
13
|
+
const app = express()
|
|
14
|
+
|
|
15
|
+
// ビルド済みフロントエンド配信
|
|
16
|
+
app.use(express.static(DIST))
|
|
17
|
+
app.get('*', (_req, res) => res.sendFile(path.join(DIST, 'index.html')))
|
|
18
|
+
|
|
19
|
+
const server = http.createServer(app)
|
|
20
|
+
|
|
21
|
+
// WebSocket: LAN 全体から受け付ける
|
|
22
|
+
const wss = new WebSocketServer({ server })
|
|
23
|
+
wss.on('connection', (ws) => {
|
|
24
|
+
createApiHandler(ws)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
server.listen(port, '0.0.0.0', () => {
|
|
29
|
+
resolve({ server, wss, port })
|
|
30
|
+
})
|
|
31
|
+
server.on('error', (err) => {
|
|
32
|
+
if (err.code === 'EADDRINUSE') {
|
|
33
|
+
resolve(startWebServer(port + 1))
|
|
34
|
+
} else {
|
|
35
|
+
reject(err)
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getLanIps() {
|
|
42
|
+
const interfaces = os.networkInterfaces()
|
|
43
|
+
const ips = []
|
|
44
|
+
for (const iface of Object.values(interfaces)) {
|
|
45
|
+
for (const entry of iface ?? []) {
|
|
46
|
+
if (entry.family === 'IPv4' && !entry.internal) {
|
|
47
|
+
ips.push(entry.address)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return ips
|
|
52
|
+
}
|