001/**
002 *
003 * Copyright 2016-2021 Florian Schmaus
004 *
005 * Licensed under the Apache License, Version 2.0 (the "License");
006 * you may not use this file except in compliance with the License.
007 * You may obtain a copy of the License at
008 *
009 *     http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.jivesoftware.smackx.iot.provisioning;
018
019import java.util.List;
020import java.util.Map;
021import java.util.Set;
022import java.util.WeakHashMap;
023import java.util.concurrent.CopyOnWriteArraySet;
024import java.util.logging.Level;
025import java.util.logging.Logger;
026
027import org.jivesoftware.smack.ConnectionCreationListener;
028import org.jivesoftware.smack.Manager;
029import org.jivesoftware.smack.SmackException.NoResponseException;
030import org.jivesoftware.smack.SmackException.NotConnectedException;
031import org.jivesoftware.smack.StanzaListener;
032import org.jivesoftware.smack.XMPPConnection;
033import org.jivesoftware.smack.XMPPConnectionRegistry;
034import org.jivesoftware.smack.XMPPException.XMPPErrorException;
035import org.jivesoftware.smack.filter.AndFilter;
036import org.jivesoftware.smack.filter.StanzaExtensionFilter;
037import org.jivesoftware.smack.filter.StanzaFilter;
038import org.jivesoftware.smack.filter.StanzaTypeFilter;
039import org.jivesoftware.smack.iqrequest.AbstractIqRequestHandler;
040import org.jivesoftware.smack.iqrequest.IQRequestHandler.Mode;
041import org.jivesoftware.smack.packet.IQ;
042import org.jivesoftware.smack.packet.Message;
043import org.jivesoftware.smack.packet.Presence;
044import org.jivesoftware.smack.packet.Stanza;
045import org.jivesoftware.smack.roster.AbstractPresenceEventListener;
046import org.jivesoftware.smack.roster.Roster;
047import org.jivesoftware.smack.roster.SubscribeListener;
048
049import org.jivesoftware.smackx.disco.ServiceDiscoveryManager;
050import org.jivesoftware.smackx.disco.packet.DiscoverInfo;
051import org.jivesoftware.smackx.iot.IoTManager;
052import org.jivesoftware.smackx.iot.discovery.IoTDiscoveryManager;
053import org.jivesoftware.smackx.iot.provisioning.element.ClearCache;
054import org.jivesoftware.smackx.iot.provisioning.element.ClearCacheResponse;
055import org.jivesoftware.smackx.iot.provisioning.element.Constants;
056import org.jivesoftware.smackx.iot.provisioning.element.Friend;
057import org.jivesoftware.smackx.iot.provisioning.element.IoTIsFriend;
058import org.jivesoftware.smackx.iot.provisioning.element.IoTIsFriendResponse;
059import org.jivesoftware.smackx.iot.provisioning.element.Unfriend;
060
061import org.jxmpp.jid.BareJid;
062import org.jxmpp.jid.DomainBareJid;
063import org.jxmpp.jid.Jid;
064import org.jxmpp.util.cache.LruCache;
065
066/**
067 * A manager for XEP-0324: Internet of Things - Provisioning.
068 *
069 * @author Florian Schmaus {@literal <flo@geekplace.eu>}
070 * @see <a href="http://xmpp.org/extensions/xep-0324.html">XEP-0324: Internet of Things - Provisioning</a>
071 */
072public final class IoTProvisioningManager extends Manager {
073
074    private static final Logger LOGGER = Logger.getLogger(IoTProvisioningManager.class.getName());
075
076    private static final StanzaFilter FRIEND_MESSAGE = new AndFilter(StanzaTypeFilter.MESSAGE,
077            new StanzaExtensionFilter(Friend.ELEMENT, Friend.NAMESPACE));
078    private static final StanzaFilter UNFRIEND_MESSAGE = new AndFilter(StanzaTypeFilter.MESSAGE,
079                    new StanzaExtensionFilter(Unfriend.ELEMENT, Unfriend.NAMESPACE));
080
081    private static final Map<XMPPConnection, IoTProvisioningManager> INSTANCES = new WeakHashMap<>();
082
083    // Ensure a IoTProvisioningManager exists for every connection.
084    static {
085        XMPPConnectionRegistry.addConnectionCreationListener(new ConnectionCreationListener() {
086            @Override
087            public void connectionCreated(XMPPConnection connection) {
088                if (!IoTManager.isAutoEnableActive()) return;
089                getInstanceFor(connection);
090            }
091        });
092    }
093
094    /**
095     * Get the manger instance responsible for the given connection.
096     *
097     * @param connection the XMPP connection.
098     * @return a manager instance.
099     */
100    public static synchronized IoTProvisioningManager getInstanceFor(XMPPConnection connection) {
101        IoTProvisioningManager manager = INSTANCES.get(connection);
102        if (manager == null) {
103            manager = new IoTProvisioningManager(connection);
104            INSTANCES.put(connection, manager);
105        }
106        return manager;
107    }
108
109    private final Roster roster;
110    private final LruCache<Jid, LruCache<BareJid, Void>> negativeFriendshipRequestCache = new LruCache<>(8);
111    private final LruCache<BareJid, Void> friendshipDeniedCache = new LruCache<>(16);
112
113    private final LruCache<BareJid, Void> friendshipRequestedCache = new LruCache<>(16);
114
115    private final Set<BecameFriendListener> becameFriendListeners = new CopyOnWriteArraySet<>();
116
117    private final Set<WasUnfriendedListener> wasUnfriendedListeners = new CopyOnWriteArraySet<>();
118
119    private Jid configuredProvisioningServer;
120
121    private IoTProvisioningManager(XMPPConnection connection) {
122        super(connection);
123
124        // Stanza listener for XEP-0324 § 3.2.3.
125        connection.addAsyncStanzaListener(new StanzaListener() {
126            @Override
127            public void processStanza(Stanza stanza) throws NotConnectedException, InterruptedException {
128                if (!isFromProvisioningService(stanza, true)) {
129                    return;
130                }
131
132                Message message = (Message) stanza;
133                Unfriend unfriend = Unfriend.from(message);
134                BareJid unfriendJid = unfriend.getJid();
135                final XMPPConnection connection = connection();
136                Roster roster = Roster.getInstanceFor(connection);
137                if (!roster.isSubscribedToMyPresence(unfriendJid)) {
138                    LOGGER.warning("Ignoring <unfriend/> request '" + stanza + "' because " + unfriendJid
139                                    + " is already not subscribed to our presence.");
140                    return;
141                }
142                Presence unsubscribed = connection.getStanzaFactory().buildPresenceStanza()
143                        .ofType(Presence.Type.unsubscribed)
144                        .to(unfriendJid)
145                        .build();
146                connection.sendStanza(unsubscribed);
147            }
148        }, UNFRIEND_MESSAGE);
149
150        // Stanza listener for XEP-0324 § 3.2.4 "Recommending Friendships".
151        // Also includes business logic for thing-to-thing friendship recommendations, which is not
152        // (yet) part of the XEP.
153        connection.addAsyncStanzaListener(new StanzaListener() {
154            @Override
155            public void processStanza(final Stanza stanza) throws NotConnectedException, InterruptedException {
156                final Message friendMessage = (Message) stanza;
157                final Friend friend = Friend.from(friendMessage);
158                final BareJid friendJid = friend.getFriend();
159
160                if (isFromProvisioningService(friendMessage, false)) {
161                    // We received a recommendation from a provisioning server.
162                    // Notify the recommended friend that we will now accept his
163                    // friendship requests.
164                    final XMPPConnection connection = connection();
165                    Friend friendNotification = new Friend(connection.getUser().asBareJid());
166                    Message notificationMessage = connection.getStanzaFactory().buildMessageStanza()
167                            .to(friendJid)
168                            .addExtension(friendNotification)
169                            .build();
170                    connection.sendStanza(notificationMessage);
171                } else {
172                    // Check is the message was send from a thing we previously
173                    // tried to become friends with. If this is the case, then
174                    // thing is likely telling us that we can become now
175                    // friends.
176                    BareJid bareFrom = friendMessage.getFrom().asBareJid();
177                    if (!friendshipDeniedCache.containsKey(bareFrom)) {
178                        LOGGER.log(Level.WARNING, "Ignoring friendship recommendation "
179                                        + friendMessage
180                                        + " because friendship to this JID was not previously denied.");
181                        return;
182                    }
183
184                    // Sanity check: If a thing recommends us itself as friend,
185                    // which should be the case once we reach this code, then
186                    // the bare 'from' JID should be equals to the JID of the
187                    // recommended friend.
188                    if (!bareFrom.equals(friendJid)) {
189                        LOGGER.log(Level.WARNING,
190                                        "Ignoring friendship recommendation " + friendMessage
191                                                        + " because it does not recommend itself, but "
192                                                        + friendJid + '.');
193                        return;
194                    }
195
196                    // Re-try the friendship request.
197                    sendFriendshipRequest(friendJid);
198                }
199            }
200        }, FRIEND_MESSAGE);
201
202        connection.registerIQRequestHandler(
203                        new AbstractIqRequestHandler(ClearCache.ELEMENT, ClearCache.NAMESPACE, IQ.Type.set, Mode.async) {
204                            @Override
205                            public IQ handleIQRequest(IQ iqRequest) {
206                                if (!isFromProvisioningService(iqRequest, true)) {
207                                    return null;
208                                }
209
210                                ClearCache clearCache = (ClearCache) iqRequest;
211
212                                // Handle <clearCache/> request.
213                                Jid from = iqRequest.getFrom();
214                                LruCache<BareJid, Void> cache = negativeFriendshipRequestCache.lookup(from);
215                                if (cache != null) {
216                                    cache.clear();
217                                }
218
219                                return new ClearCacheResponse(clearCache);
220                            }
221                        });
222
223        roster = Roster.getInstanceFor(connection);
224        roster.addSubscribeListener(new SubscribeListener() {
225            @Override
226            public SubscribeAnswer processSubscribe(Jid from, Presence subscribeRequest) {
227                // First check if the subscription request comes from a known registry and accept the request if so.
228                try {
229                    if (IoTDiscoveryManager.getInstanceFor(connection()).isRegistry(from.asBareJid())) {
230                        return SubscribeAnswer.Approve;
231                    }
232                }
233                catch (NoResponseException | XMPPErrorException | NotConnectedException | InterruptedException e) {
234                    LOGGER.log(Level.WARNING, "Could not determine if " + from + " is a registry", e);
235                }
236
237                Jid provisioningServer = null;
238                try {
239                    provisioningServer = getConfiguredProvisioningServer();
240                }
241                catch (NoResponseException | XMPPErrorException | NotConnectedException | InterruptedException e) {
242                    LOGGER.log(Level.WARNING,
243                                    "Could not determine provisioning server. Ignoring friend request from " + from, e);
244                }
245                if (provisioningServer == null) {
246                    return null;
247                }
248
249                boolean isFriend;
250                try {
251                    isFriend = isFriend(provisioningServer, from.asBareJid());
252                }
253                catch (NoResponseException | XMPPErrorException | NotConnectedException | InterruptedException e) {
254                    LOGGER.log(Level.WARNING, "Could not determine if " + from + " is a friend.", e);
255                    return null;
256                }
257
258                if (isFriend) {
259                    return SubscribeAnswer.Approve;
260                }
261                else {
262                    return SubscribeAnswer.Deny;
263                }
264            }
265        });
266
267        roster.addPresenceEventListener(new AbstractPresenceEventListener() {
268            @Override
269            public void presenceSubscribed(BareJid address, Presence subscribedPresence) {
270                friendshipRequestedCache.remove(address);
271                for (BecameFriendListener becameFriendListener : becameFriendListeners) {
272                    becameFriendListener.becameFriend(address, subscribedPresence);
273                }
274            }
275            @Override
276            public void presenceUnsubscribed(BareJid address, Presence unsubscribedPresence) {
277                if (friendshipRequestedCache.containsKey(address)) {
278                    friendshipDeniedCache.put(address, null);
279                }
280                for (WasUnfriendedListener wasUnfriendedListener : wasUnfriendedListeners) {
281                    wasUnfriendedListener.wasUnfriendedListener(address, unsubscribedPresence);
282                }
283            }
284        });
285    }
286
287    /**
288     * Set the configured provisioning server. Use <code>null</code> as provisioningServer to use
289     * automatic discovery of the provisioning server (the default behavior).
290     *
291     * @param provisioningServer TODO javadoc me please
292     */
293    public void setConfiguredProvisioningServer(Jid provisioningServer) {
294        this.configuredProvisioningServer = provisioningServer;
295    }
296
297    public Jid getConfiguredProvisioningServer()
298                    throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
299        if (configuredProvisioningServer == null) {
300            configuredProvisioningServer = findProvisioningServerComponent();
301        }
302        return configuredProvisioningServer;
303    }
304
305    /**
306     * Try to find a provisioning server component.
307     *
308     * @return the XMPP address of the provisioning server component if one was found.
309     * @throws NoResponseException if there was no response from the remote entity.
310     * @throws XMPPErrorException if there was an XMPP error returned.
311     * @throws NotConnectedException if the XMPP connection is not connected.
312     * @throws InterruptedException if the calling thread was interrupted.
313     * @see <a href="http://xmpp.org/extensions/xep-0324.html#servercomponent">XEP-0324 § 3.1.2 Provisioning Server as a server component</a>
314     */
315    public DomainBareJid findProvisioningServerComponent() throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
316        final XMPPConnection connection = connection();
317        ServiceDiscoveryManager sdm = ServiceDiscoveryManager.getInstanceFor(connection);
318        List<DiscoverInfo> discoverInfos = sdm.findServicesDiscoverInfo(Constants.IOT_PROVISIONING_NAMESPACE, true, true);
319        if (discoverInfos.isEmpty()) {
320            return null;
321        }
322        Jid jid = discoverInfos.get(0).getFrom();
323        assert jid.isDomainBareJid();
324        return jid.asDomainBareJid();
325    }
326
327    /**
328     * As the given provisioning server is the given JID is a friend.
329     *
330     * @param provisioningServer the provisioning server to ask.
331     * @param friendInQuestion the JID to ask about.
332     * @return <code>true</code> if the JID is a friend, <code>false</code> otherwise.
333     * @throws NoResponseException if there was no response from the remote entity.
334     * @throws XMPPErrorException if there was an XMPP error returned.
335     * @throws NotConnectedException if the XMPP connection is not connected.
336     * @throws InterruptedException if the calling thread was interrupted.
337     */
338    public boolean isFriend(Jid provisioningServer, BareJid friendInQuestion) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
339        LruCache<BareJid, Void> cache = negativeFriendshipRequestCache.lookup(provisioningServer);
340        if (cache != null && cache.containsKey(friendInQuestion)) {
341            // We hit a cached negative isFriend response for this provisioning server.
342            return false;
343        }
344
345        IoTIsFriend iotIsFriend = new IoTIsFriend(friendInQuestion);
346        iotIsFriend.setTo(provisioningServer);
347        IoTIsFriendResponse response = connection().sendIqRequestAndWaitForResponse(iotIsFriend);
348        assert response.getJid().equals(friendInQuestion);
349        boolean isFriend = response.getIsFriendResult();
350        if (!isFriend) {
351            // Cache the negative is friend response.
352            if (cache == null) {
353                cache = new LruCache<>(1024);
354                negativeFriendshipRequestCache.put(provisioningServer, cache);
355            }
356            cache.put(friendInQuestion, null);
357        }
358        return isFriend;
359    }
360
361    public boolean iAmFriendOf(BareJid otherJid) {
362        return roster.iAmSubscribedTo(otherJid);
363    }
364
365    public void sendFriendshipRequest(BareJid bareJid) throws NotConnectedException, InterruptedException {
366        XMPPConnection connection = connection();
367        Presence presence = connection.getStanzaFactory().buildPresenceStanza()
368            .ofType(Presence.Type.subscribe)
369            .to(bareJid)
370            .build();
371
372        friendshipRequestedCache.put(bareJid, null);
373
374        connection().sendStanza(presence);
375    }
376
377    public void sendFriendshipRequestIfRequired(BareJid jid) throws NotConnectedException, InterruptedException {
378        if (iAmFriendOf(jid)) return;
379
380        sendFriendshipRequest(jid);
381    }
382
383    public boolean isMyFriend(Jid friendInQuestion) {
384        return roster.isSubscribedToMyPresence(friendInQuestion);
385    }
386
387    public void unfriend(Jid friend) throws NotConnectedException, InterruptedException {
388        if (isMyFriend(friend)) {
389            XMPPConnection connection = connection();
390            Presence presence = connection.getStanzaFactory().buildPresenceStanza()
391                    .ofType(Presence.Type.unsubscribed)
392                    .to(friend)
393                    .build();
394            connection.sendStanza(presence);
395        }
396    }
397
398    public boolean addBecameFriendListener(BecameFriendListener becameFriendListener) {
399        return becameFriendListeners.add(becameFriendListener);
400    }
401
402    public boolean removeBecameFriendListener(BecameFriendListener becameFriendListener) {
403        return becameFriendListeners.remove(becameFriendListener);
404    }
405
406    public boolean addWasUnfriendedListener(WasUnfriendedListener wasUnfriendedListener) {
407        return wasUnfriendedListeners.add(wasUnfriendedListener);
408    }
409
410    public boolean removeWasUnfriendedListener(WasUnfriendedListener wasUnfriendedListener) {
411        return wasUnfriendedListeners.remove(wasUnfriendedListener);
412    }
413
414    private boolean isFromProvisioningService(Stanza stanza, boolean log) {
415        Jid provisioningServer;
416        try {
417            provisioningServer = getConfiguredProvisioningServer();
418        }
419        catch (NotConnectedException | InterruptedException | NoResponseException | XMPPErrorException e) {
420            LOGGER.log(Level.WARNING, "Could determine provisioning server", e);
421            return false;
422        }
423        if (provisioningServer == null) {
424            if (log) {
425                LOGGER.warning("Ignoring request '" + stanza
426                                + "' because no provisioning server configured.");
427            }
428            return false;
429        }
430        if (!provisioningServer.equals(stanza.getFrom())) {
431            if (log) {
432                LOGGER.warning("Ignoring  request '" + stanza
433                                + "' because not from provisioning server '" + provisioningServer
434                                + "'.");
435            }
436            return false;
437        }
438        return true;
439    }
440}